mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-02 10:29:02 +08:00
refactor: quick panel and inputbar tools (#10142)
* refactor: add QuickPanelReservedSymbol * refactor: pass assistant id instead of assistant object * refactor: simplify InputbarTools props * refactor: add root symbol * refactor: merge file panel to attachment button * refactor: extract ActionButton for reuse * chore: update CLAUDE.md * fix: colors * refactor: add more reserved symbols * fix: keep updateAssistant stable * refactor(components): replace styled-components with cn utility in ActionIconButton - Replace styled-components with cn utility from @heroui/react for better maintainability - Add new --icon and --color-icon CSS variables for consistent icon coloring - Simplify button styling using Tailwind CSS classes --------- Co-authored-by: icarus <eurfelux@gmail.com>
This commit is contained in:
parent
5451e2f34a
commit
1a5138c5b1
@ -9,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- **Prerequisites**: Node.js v22.x.x or higher, Yarn 4.9.1
|
||||
- **Setup Yarn**: `corepack enable && corepack prepare yarn@4.9.1 --activate`
|
||||
- **Install Dependencies**: `yarn install`
|
||||
- **Add New Dependencies**: `yarn add -D` for renderer-specific dependencies, `yarn add` for others.
|
||||
|
||||
### Development
|
||||
|
||||
|
||||
@ -53,6 +53,7 @@
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
--icon: #00000099;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@ -87,6 +88,7 @@
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
--icon: #ffffff99;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@ -128,6 +130,7 @@
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--animate-marquee: marquee var(--duration) infinite linear;
|
||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
||||
--color-icon: var(--icon);
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
|
||||
30
src/renderer/src/components/Buttons/ActionIconButton.tsx
Normal file
30
src/renderer/src/components/Buttons/ActionIconButton.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { cn } from '@heroui/react'
|
||||
import { Button, ButtonProps } from 'antd'
|
||||
import React, { memo } from 'react'
|
||||
|
||||
interface ActionIconButtonProps extends ButtonProps {
|
||||
children: React.ReactNode
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple action button rendered as an icon
|
||||
*/
|
||||
const ActionIconButton: React.FC<ActionIconButtonProps> = ({ children, active = false, className, ...props }) => {
|
||||
return (
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
className={cn(
|
||||
'flex h-[30px] w-[30px] cursor-pointer flex-row items-center justify-center border-none p-0 text-base transition-all duration-300 ease-in-out [&_.anticon]:text-icon [&_.icon-a-addchat]:mb-[-2px] [&_.icon-a-addchat]:text-lg [&_.icon]:text-icon [&_.iconfont]:text-icon [&_.lucide]:text-icon',
|
||||
active &&
|
||||
'[&_.anticon]:text-primary! [&_.icon]:text-primary! [&_.iconfont]:text-primary! [&_.lucide]:text-primary!',
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ActionIconButton)
|
||||
1
src/renderer/src/components/Buttons/index.ts
Normal file
1
src/renderer/src/components/Buttons/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as ActionIconButton } from './ActionIconButton'
|
||||
@ -1,4 +1,4 @@
|
||||
import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
|
||||
import { Tooltip } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
@ -364,23 +364,23 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
<ToolBar>
|
||||
{showUserToggle && (
|
||||
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
|
||||
<ActionIconButton onClick={userOutlinedButtonOnClick}>
|
||||
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
|
||||
<ActionIconButton onClick={caseSensitiveButtonOnClick}>
|
||||
<CaseSensitive
|
||||
size={18}
|
||||
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
|
||||
<ActionIconButton onClick={wholeWordButtonOnClick}>
|
||||
<WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
</ToolBar>
|
||||
</InputWrapper>
|
||||
@ -397,15 +397,15 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
)}
|
||||
</SearchResults>
|
||||
<ToolBar>
|
||||
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
|
||||
<ActionIconButton onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
|
||||
<ChevronUp size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
|
||||
</ActionIconButton>
|
||||
<ActionIconButton onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
|
||||
<ChevronDown size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={closeButtonOnClick}>
|
||||
</ActionIconButton>
|
||||
<ActionIconButton onClick={closeButtonOnClick}>
|
||||
<X size={18} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</ToolBar>
|
||||
</SearchBarContainer>
|
||||
</NarrowLayout>
|
||||
|
||||
@ -1,5 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
export enum QuickPanelReservedSymbol {
|
||||
Root = '/',
|
||||
File = 'file',
|
||||
KnowledgeBase = '#',
|
||||
MentionModels = '@',
|
||||
QuickPhrases = 'quick-phrases',
|
||||
Thinking = 'thinking',
|
||||
WebSearch = '?',
|
||||
Mcp = 'mcp',
|
||||
McpPrompt = 'mcp-prompt',
|
||||
McpResource = 'mcp-resource'
|
||||
}
|
||||
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
||||
export type QuickPanelTriggerInfo = {
|
||||
type: 'input' | 'button'
|
||||
|
||||
@ -172,7 +172,7 @@ export function useAssistant(id: string) {
|
||||
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
|
||||
[assistant, dispatch]
|
||||
),
|
||||
updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)),
|
||||
updateAssistant: useCallback((assistant: Partial<Assistant>) => dispatch(updateAssistant(assistant)), [dispatch]),
|
||||
updateAssistantSettings
|
||||
}
|
||||
}
|
||||
|
||||
@ -362,8 +362,9 @@
|
||||
"translate": "Translate to {{target_language}}",
|
||||
"translating": "Translating...",
|
||||
"upload": {
|
||||
"attachment": "Upload attachment",
|
||||
"document": "Upload document file (model does not support images)",
|
||||
"label": "Upload image or document file",
|
||||
"image_or_document": "Upload image or document file",
|
||||
"upload_from_local": "Upload local file..."
|
||||
},
|
||||
"url_context": "URL Context",
|
||||
|
||||
@ -363,8 +363,9 @@
|
||||
"translate": "翻译成 {{target_language}}",
|
||||
"translating": "翻译中...",
|
||||
"upload": {
|
||||
"attachment": "上传附件",
|
||||
"document": "上传文档(模型不支持图片)",
|
||||
"label": "上传图片或文档",
|
||||
"image_or_document": "上传图片或文档",
|
||||
"upload_from_local": "上传本地文件..."
|
||||
},
|
||||
"url_context": "网页上下文",
|
||||
|
||||
@ -362,8 +362,9 @@
|
||||
"translate": "翻譯成 {{target_language}}",
|
||||
"translating": "翻譯中...",
|
||||
"upload": {
|
||||
"attachment": "上傳附件",
|
||||
"document": "上傳文件(模型不支援圖片)",
|
||||
"label": "上傳圖片或文件",
|
||||
"image_or_document": "上傳圖片或文件",
|
||||
"upload_from_local": "上傳本地文件..."
|
||||
},
|
||||
"url_context": "網頁上下文",
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import { FileType } from '@renderer/types'
|
||||
import { filterSupportedFiles } from '@renderer/utils/file'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { FileType, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { filterSupportedFiles, formatFileSize } from '@renderer/utils/file'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Paperclip } from 'lucide-react'
|
||||
import { FC, useCallback, useImperativeHandle, useState } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import { FileSearch, FileText, Paperclip, Upload } from 'lucide-react'
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface AttachmentButtonRef {
|
||||
openQuickPanel: () => void
|
||||
openFileSelectDialog: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@ -14,24 +19,17 @@ interface Props {
|
||||
couldAddImageFile: boolean
|
||||
extensions: string[]
|
||||
files: FileType[]
|
||||
setFiles: (files: FileType[]) => void
|
||||
ToolbarButton: any
|
||||
setFiles: Dispatch<SetStateAction<FileType[]>>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const AttachmentButton: FC<Props> = ({
|
||||
ref,
|
||||
couldAddImageFile,
|
||||
extensions,
|
||||
files,
|
||||
setFiles,
|
||||
ToolbarButton,
|
||||
disabled
|
||||
}) => {
|
||||
const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files, setFiles, disabled }) => {
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const { bases: knowledgeBases } = useKnowledgeBases()
|
||||
const [selecting, setSelecting] = useState<boolean>(false)
|
||||
|
||||
const onSelectFile = useCallback(async () => {
|
||||
const openFileSelectDialog = useCallback(async () => {
|
||||
if (selecting) {
|
||||
return
|
||||
}
|
||||
@ -70,23 +68,88 @@ const AttachmentButton: FC<Props> = ({
|
||||
}
|
||||
}, [extensions, files, selecting, setFiles, t])
|
||||
|
||||
const openKnowledgeFileList = useCallback(
|
||||
(base: KnowledgeBase) => {
|
||||
quickPanel.open({
|
||||
title: base.name,
|
||||
list: base.items
|
||||
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
|
||||
.map((file) => {
|
||||
const fileContent = file.content as FileType
|
||||
return {
|
||||
label: fileContent.origin_name || fileContent.name,
|
||||
description:
|
||||
formatFileSize(fileContent.size) + ' · ' + dayjs(fileContent.created_at).format('YYYY-MM-DD HH:mm'),
|
||||
icon: <FileText />,
|
||||
isSelected: files.some((f) => f.path === fileContent.path),
|
||||
action: async ({ item }) => {
|
||||
item.isSelected = !item.isSelected
|
||||
if (fileContent.path) {
|
||||
setFiles((prevFiles) => {
|
||||
const fileExists = prevFiles.some((f) => f.path === fileContent.path)
|
||||
if (fileExists) {
|
||||
return prevFiles.filter((f) => f.path !== fileContent.path)
|
||||
} else {
|
||||
return fileContent ? [...prevFiles, fileContent] : prevFiles
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
symbol: QuickPanelReservedSymbol.File,
|
||||
multiple: true
|
||||
})
|
||||
},
|
||||
[files, quickPanel, setFiles]
|
||||
)
|
||||
|
||||
const items = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t('chat.input.upload.upload_from_local'),
|
||||
description: '',
|
||||
icon: <Upload />,
|
||||
action: () => openFileSelectDialog()
|
||||
},
|
||||
...knowledgeBases.map((base) => {
|
||||
const length = base.items?.filter(
|
||||
(item): item is KnowledgeItem => ['file', 'note'].includes(item.type) && typeof item.content !== 'string'
|
||||
).length
|
||||
return {
|
||||
label: base.name,
|
||||
description: `${length} ${t('files.count')}`,
|
||||
icon: <FileSearch />,
|
||||
disabled: length === 0,
|
||||
isMenu: true,
|
||||
action: () => openKnowledgeFileList(base)
|
||||
}
|
||||
})
|
||||
]
|
||||
}, [knowledgeBases, openFileSelectDialog, openKnowledgeFileList, t])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
onSelectFile()
|
||||
}, [onSelectFile])
|
||||
quickPanel.open({
|
||||
title: t('chat.input.upload.attachment'),
|
||||
list: items,
|
||||
symbol: QuickPanelReservedSymbol.File
|
||||
})
|
||||
}, [items, quickPanel, t])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
openQuickPanel,
|
||||
openFileSelectDialog
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={couldAddImageFile ? t('chat.input.upload.label') : t('chat.input.upload.document')}
|
||||
title={couldAddImageFile ? t('chat.input.upload.image_or_document') : t('chat.input.upload.document')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onSelectFile} disabled={disabled}>
|
||||
<Paperclip size={18} style={{ color: files.length ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
<ActionIconButton onClick={openFileSelectDialog} active={files.length > 0} disabled={disabled}>
|
||||
<Paperclip size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { isGenerateImageModel } from '@renderer/config/models'
|
||||
import { Assistant, Model } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
@ -8,11 +9,10 @@ import { useTranslation } from 'react-i18next'
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
model: Model
|
||||
ToolbarButton: any
|
||||
onEnableGenerateImage: () => void
|
||||
}
|
||||
|
||||
const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEnableGenerateImage }) => {
|
||||
const GenerateImageButton: FC<Props> = ({ model, assistant, onEnableGenerateImage }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@ -23,9 +23,12 @@ const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEna
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" disabled={!isGenerateImageModel(model)} onClick={onEnableGenerateImage}>
|
||||
<Image size={18} color={assistant.enableGenerateImage ? 'var(--color-primary)' : 'var(--color-icon)'} />
|
||||
</ToolbarButton>
|
||||
<ActionIconButton
|
||||
onClick={onEnableGenerateImage}
|
||||
active={assistant.enableGenerateImage}
|
||||
disabled={!isGenerateImageModel(model)}>
|
||||
<Image size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,25 +1,23 @@
|
||||
import { HolderOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelReservedSymbol, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import {
|
||||
isAutoEnableImageGenerationModel,
|
||||
isGenerateImageModel,
|
||||
isGenerateImageModels,
|
||||
isMandatoryWebSearchModel,
|
||||
isSupportedReasoningEffortModel,
|
||||
isSupportedThinkingTokenModel,
|
||||
isVisionModel,
|
||||
isVisionModels,
|
||||
isWebSearchModel
|
||||
} from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
@ -27,7 +25,6 @@ import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import PasteService from '@renderer/services/PasteService'
|
||||
import { spanManagerService } from '@renderer/services/SpanManagerService'
|
||||
import { estimateTextTokens as estimateTxtTokens, estimateUserPromptUsage } from '@renderer/services/TokenService'
|
||||
@ -36,9 +33,9 @@ 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, FileTypes, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
|
||||
import { Assistant, FileType, KnowledgeBase, Model, Topic } from '@renderer/types'
|
||||
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||
import { classNames, delay, filterSupportedFiles, formatFileSize } from '@renderer/utils'
|
||||
import { classNames, delay, filterSupportedFiles } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
import {
|
||||
getFilesFromDropEvent,
|
||||
@ -46,14 +43,12 @@ import {
|
||||
getTextFromDropEvent,
|
||||
isSendMessageKeyPressed
|
||||
} from '@renderer/utils/input'
|
||||
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { Tooltip } from 'antd'
|
||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import dayjs from 'dayjs'
|
||||
import { debounce, isEmpty } from 'lodash'
|
||||
import { CirclePause, FileSearch, FileText, Upload } from 'lucide-react'
|
||||
import { CirclePause } from 'lucide-react'
|
||||
import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@ -114,7 +109,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const [textareaHeight, setTextareaHeight] = useState<number>()
|
||||
const startDragY = useRef<number>(0)
|
||||
const startHeight = useRef<number>(0)
|
||||
const { bases: knowledgeBases } = useKnowledgeBases()
|
||||
const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode)
|
||||
const isVisionAssistant = useMemo(() => isVisionModel(model), [model])
|
||||
const isGenerateImageAssistant = useMemo(() => isGenerateImageModel(model), [model])
|
||||
@ -134,11 +128,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
[mentionedModels, isGenerateImageAssistant]
|
||||
)
|
||||
|
||||
// 仅允许在不含图片文件时mention非视觉模型
|
||||
const couldMentionNotVisionModel = useMemo(() => {
|
||||
return !files.some((file) => file.type === FileTypes.IMAGE)
|
||||
}, [files])
|
||||
|
||||
// 允许在支持视觉或生成图片时添加图片文件
|
||||
const couldAddImageFile = useMemo(() => {
|
||||
return isVisionSupported || isGenerateImageSupported
|
||||
@ -185,8 +174,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
|
||||
const inputTokenCount = showInputEstimatedTokens ? tokenCount : 0
|
||||
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
const cleanTopicShortcut = useShortcutDisplay('clear_topic')
|
||||
const inputEmpty = isEmpty(text.trim()) && files.length === 0
|
||||
|
||||
_text = text
|
||||
@ -279,72 +266,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea])
|
||||
|
||||
const openKnowledgeFileList = useCallback(
|
||||
(base: KnowledgeBase) => {
|
||||
quickPanel.open({
|
||||
title: base.name,
|
||||
list: base.items
|
||||
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
|
||||
.map((file) => {
|
||||
const fileContent = file.content as FileType
|
||||
return {
|
||||
label: fileContent.origin_name || fileContent.name,
|
||||
description:
|
||||
formatFileSize(fileContent.size) + ' · ' + dayjs(fileContent.created_at).format('YYYY-MM-DD HH:mm'),
|
||||
icon: <FileText />,
|
||||
isSelected: files.some((f) => f.path === fileContent.path),
|
||||
action: async ({ item }) => {
|
||||
item.isSelected = !item.isSelected
|
||||
if (fileContent.path) {
|
||||
setFiles((prevFiles) => {
|
||||
const fileExists = prevFiles.some((f) => f.path === fileContent.path)
|
||||
if (fileExists) {
|
||||
return prevFiles.filter((f) => f.path !== fileContent.path)
|
||||
} else {
|
||||
return fileContent ? [...prevFiles, fileContent] : prevFiles
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
symbol: 'file',
|
||||
multiple: true
|
||||
})
|
||||
},
|
||||
[files, quickPanel]
|
||||
)
|
||||
|
||||
const openSelectFileMenu = useCallback(() => {
|
||||
quickPanel.open({
|
||||
title: t('chat.input.upload.label'),
|
||||
list: [
|
||||
{
|
||||
label: t('chat.input.upload.upload_from_local'),
|
||||
description: '',
|
||||
icon: <Upload />,
|
||||
action: () => {
|
||||
inputbarToolsRef.current?.openAttachmentQuickPanel()
|
||||
}
|
||||
},
|
||||
...knowledgeBases.map((base) => {
|
||||
const length = base.items?.filter(
|
||||
(item): item is KnowledgeItem => ['file', 'note'].includes(item.type) && typeof item.content !== 'string'
|
||||
).length
|
||||
return {
|
||||
label: base.name,
|
||||
description: `${length} ${t('files.count')}`,
|
||||
icon: <FileSearch />,
|
||||
disabled: length === 0,
|
||||
isMenu: true,
|
||||
action: () => openKnowledgeFileList(base)
|
||||
}
|
||||
})
|
||||
],
|
||||
symbol: 'file'
|
||||
})
|
||||
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t, inputbarToolsRef])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// 按下Tab键,自动选中${xxx}
|
||||
if (event.key === 'Tab' && inputFocus) {
|
||||
@ -512,35 +433,31 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const lastSymbol = newText[cursorPosition - 1]
|
||||
|
||||
// 触发符号为 '/':若当前未打开或符号不同,则切换/打开
|
||||
if (enableQuickPanelTriggers && lastSymbol === '/') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== '/') {
|
||||
if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.Root) {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
|
||||
quickPanel.close('switch-symbol')
|
||||
}
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== '/') {
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
|
||||
const quickPanelMenu =
|
||||
inputbarToolsRef.current?.getQuickPanelMenu({
|
||||
t,
|
||||
files,
|
||||
couldAddImageFile,
|
||||
text: newText,
|
||||
openSelectFileMenu,
|
||||
translate
|
||||
}) || []
|
||||
|
||||
quickPanel.open({
|
||||
title: t('settings.quickPanel.title'),
|
||||
list: quickPanelMenu,
|
||||
symbol: '/'
|
||||
symbol: QuickPanelReservedSymbol.Root
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 触发符号为 '@':若当前未打开或符号不同,则切换/打开
|
||||
if (enableQuickPanelTriggers && lastSymbol === '@') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== '@') {
|
||||
if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.MentionModels) {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
|
||||
quickPanel.close('switch-symbol')
|
||||
}
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== '@') {
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
|
||||
inputbarToolsRef.current?.openMentionModelsPanel({
|
||||
type: 'input',
|
||||
position: cursorPosition - 1,
|
||||
@ -549,7 +466,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}
|
||||
},
|
||||
[enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate]
|
||||
[enableQuickPanelTriggers, quickPanel, t, translate]
|
||||
)
|
||||
|
||||
const onPaste = useCallback(
|
||||
@ -765,11 +682,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : [])
|
||||
}, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon])
|
||||
|
||||
const handleKnowledgeBaseSelect = (bases?: KnowledgeBase[]) => {
|
||||
updateAssistant({ ...assistant, knowledge_bases: bases })
|
||||
setSelectedKnowledgeBases(bases ?? [])
|
||||
}
|
||||
|
||||
const handleRemoveModel = (model: Model) => {
|
||||
setMentionedModels(mentionedModels.filter((m) => m.id !== model.id))
|
||||
}
|
||||
@ -783,10 +695,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
setSelectedKnowledgeBases(newKnowledgeBases ?? [])
|
||||
}
|
||||
|
||||
const onEnableGenerateImage = () => {
|
||||
updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWebSearchModel(model) && assistant.enableWebSearch) {
|
||||
updateAssistant({ ...assistant, enableWebSearch: false })
|
||||
@ -806,24 +714,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}, [assistant, model, updateAssistant])
|
||||
|
||||
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 {
|
||||
logger.error('Cannot add non-vision model when images are uploaded')
|
||||
}
|
||||
},
|
||||
[couldMentionNotVisionModel]
|
||||
)
|
||||
|
||||
const onClearMentionModels = useCallback(() => setMentionedModels([]), [setMentionedModels])
|
||||
|
||||
const onToggleExpanded = () => {
|
||||
const currentlyExpanded = expanded || !!textareaHeight
|
||||
const shouldExpand = !currentlyExpanded
|
||||
@ -848,8 +738,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
|
||||
const isExpanded = expanded || !!textareaHeight
|
||||
const showThinkingButton = isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)
|
||||
const showMcpTools = isSupportedToolUse(assistant) || isPromptToolUse(assistant)
|
||||
|
||||
if (isMultiSelectMode) {
|
||||
return null
|
||||
@ -921,47 +809,38 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
<Toolbar>
|
||||
<InputbarTools
|
||||
ref={inputbarToolsRef}
|
||||
assistant={assistant}
|
||||
assistantId={assistant.id}
|
||||
model={model}
|
||||
files={files}
|
||||
extensions={supportedExts}
|
||||
setFiles={setFiles}
|
||||
showThinkingButton={showThinkingButton}
|
||||
showKnowledgeIcon={showKnowledgeIcon && showMcpTools}
|
||||
showMcpTools={showMcpTools}
|
||||
selectedKnowledgeBases={selectedKnowledgeBases}
|
||||
handleKnowledgeBaseSelect={handleKnowledgeBaseSelect}
|
||||
setText={setText}
|
||||
resizeTextArea={resizeTextArea}
|
||||
mentionModels={mentionedModels}
|
||||
onMentionModel={onMentionModel}
|
||||
onClearMentionModels={onClearMentionModels}
|
||||
couldMentionNotVisionModel={couldMentionNotVisionModel}
|
||||
selectedKnowledgeBases={selectedKnowledgeBases}
|
||||
setSelectedKnowledgeBases={setSelectedKnowledgeBases}
|
||||
mentionedModels={mentionedModels}
|
||||
setMentionedModels={setMentionedModels}
|
||||
couldAddImageFile={couldAddImageFile}
|
||||
onEnableGenerateImage={onEnableGenerateImage}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
addNewTopic={addNewTopic}
|
||||
clearTopic={clearTopic}
|
||||
onNewContext={onNewContext}
|
||||
newTopicShortcut={newTopicShortcut}
|
||||
cleanTopicShortcut={cleanTopicShortcut}
|
||||
/>
|
||||
<ToolbarMenu>
|
||||
<TokenCount
|
||||
estimateTokenCount={estimateTokenCount}
|
||||
inputTokenCount={inputTokenCount}
|
||||
contextCount={contextCount}
|
||||
ToolbarButton={ToolbarButton}
|
||||
onClick={onNewContext}
|
||||
/>
|
||||
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
|
||||
<SendMessageButton sendMessage={sendMessage} disabled={inputEmpty} />
|
||||
{loading && (
|
||||
<Tooltip placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2 }}>
|
||||
<ActionIconButton onClick={onPause} style={{ marginRight: -2 }}>
|
||||
<CirclePause size={20} color="var(--color-error)" />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ToolbarMenu>
|
||||
@ -1076,45 +955,4 @@ const ToolbarMenu = styled.div`
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
export const ToolbarButton = styled(Button)`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 16px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-icon);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
&.anticon,
|
||||
&.iconfont {
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-icon);
|
||||
}
|
||||
.icon-a-addchat {
|
||||
font-size: 18px;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
.anticon,
|
||||
.iconfont {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-primary) !important;
|
||||
.anticon,
|
||||
.iconfont,
|
||||
.chevron-icon {
|
||||
color: var(--color-white-soft);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default Inputbar
|
||||
|
||||
@ -1,12 +1,26 @@
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
|
||||
import { loggerService } from '@logger'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||
import { isGeminiModel, isGenerateImageModel, isMandatoryWebSearchModel } from '@renderer/config/models'
|
||||
import {
|
||||
isGeminiModel,
|
||||
isGenerateImageModel,
|
||||
isMandatoryWebSearchModel,
|
||||
isSupportedReasoningEffortModel,
|
||||
isSupportedThinkingTokenModel,
|
||||
isVisionModel
|
||||
} from '@renderer/config/models'
|
||||
import { isSupportUrlContextProvider } from '@renderer/config/providers'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
|
||||
import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types'
|
||||
import { FileType, FileTypes, KnowledgeBase, Model } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||
import { Divider, Dropdown, Tooltip } from 'antd'
|
||||
import { ItemType } from 'antd/es/menu/interface'
|
||||
import {
|
||||
@ -32,7 +46,6 @@ import styled from 'styled-components'
|
||||
|
||||
import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton'
|
||||
import GenerateImageButton from './GenerateImageButton'
|
||||
import { ToolbarButton } from './Inputbar'
|
||||
import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
|
||||
import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton'
|
||||
import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton'
|
||||
@ -42,47 +55,33 @@ import ThinkingButton, { ThinkingButtonRef } from './ThinkingButton'
|
||||
import UrlContextButton, { UrlContextButtonRef } from './UrlContextbutton'
|
||||
import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton'
|
||||
|
||||
const logger = loggerService.withContext('InputbarTools')
|
||||
|
||||
export interface InputbarToolsRef {
|
||||
getQuickPanelMenu: (params: {
|
||||
t: (key: string, options?: any) => string
|
||||
files: FileType[]
|
||||
couldAddImageFile: boolean
|
||||
text: string
|
||||
openSelectFileMenu: () => void
|
||||
translate: () => void
|
||||
}) => QuickPanelListItem[]
|
||||
getQuickPanelMenu: (params: { text: string; translate: () => void }) => QuickPanelListItem[]
|
||||
openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
|
||||
openAttachmentQuickPanel: () => void
|
||||
}
|
||||
|
||||
export interface InputbarToolsProps {
|
||||
assistant: Assistant
|
||||
assistantId: string
|
||||
model: Model
|
||||
files: FileType[]
|
||||
setFiles: (files: FileType[]) => void
|
||||
setFiles: Dispatch<SetStateAction<FileType[]>>
|
||||
extensions: string[]
|
||||
showThinkingButton: boolean
|
||||
showKnowledgeIcon: boolean
|
||||
showMcpTools: boolean
|
||||
selectedKnowledgeBases: KnowledgeBase[]
|
||||
handleKnowledgeBaseSelect: (bases?: KnowledgeBase[]) => void
|
||||
setText: Dispatch<SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
mentionModels: Model[]
|
||||
onMentionModel: (model: Model) => void
|
||||
onClearMentionModels: () => void
|
||||
couldMentionNotVisionModel: boolean
|
||||
selectedKnowledgeBases: KnowledgeBase[]
|
||||
setSelectedKnowledgeBases: Dispatch<SetStateAction<KnowledgeBase[]>>
|
||||
mentionedModels: Model[]
|
||||
setMentionedModels: Dispatch<SetStateAction<Model[]>>
|
||||
couldAddImageFile: boolean
|
||||
onEnableGenerateImage: () => void
|
||||
isExpanded: boolean
|
||||
onToggleExpanded: () => void
|
||||
|
||||
addNewTopic: () => void
|
||||
clearTopic: () => void
|
||||
onNewContext: () => void
|
||||
|
||||
newTopicShortcut: string
|
||||
cleanTopicShortcut: string
|
||||
}
|
||||
|
||||
interface ToolButtonConfig {
|
||||
@ -100,34 +99,27 @@ const DraggablePortal = ({ children, isDragging }) => {
|
||||
|
||||
const InputbarTools = ({
|
||||
ref,
|
||||
assistant,
|
||||
assistantId,
|
||||
model,
|
||||
files,
|
||||
setFiles,
|
||||
showThinkingButton,
|
||||
showKnowledgeIcon,
|
||||
showMcpTools,
|
||||
selectedKnowledgeBases,
|
||||
handleKnowledgeBaseSelect,
|
||||
setText,
|
||||
resizeTextArea,
|
||||
mentionModels,
|
||||
onMentionModel,
|
||||
onClearMentionModels,
|
||||
couldMentionNotVisionModel,
|
||||
selectedKnowledgeBases,
|
||||
setSelectedKnowledgeBases,
|
||||
mentionedModels,
|
||||
setMentionedModels,
|
||||
couldAddImageFile,
|
||||
onEnableGenerateImage,
|
||||
isExpanded: isExpended,
|
||||
onToggleExpanded: onToggleExpended,
|
||||
addNewTopic,
|
||||
clearTopic,
|
||||
onNewContext,
|
||||
newTopicShortcut,
|
||||
cleanTopicShortcut,
|
||||
extensions
|
||||
}: InputbarToolsProps & { ref?: React.RefObject<InputbarToolsRef | null> }) => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
|
||||
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null)
|
||||
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
|
||||
@ -143,6 +135,54 @@ const InputbarTools = ({
|
||||
|
||||
const [targetTool, setTargetTool] = useState<ToolButtonConfig | null>(null)
|
||||
|
||||
const showThinkingButton = useMemo(
|
||||
() => isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model),
|
||||
[model]
|
||||
)
|
||||
|
||||
const showMcpServerButton = useMemo(() => isSupportedToolUse(assistant) || isPromptToolUse(assistant), [assistant])
|
||||
|
||||
const knowledgeSidebarEnabled = useSidebarIconShow('knowledge')
|
||||
const showKnowledgeBaseButton = knowledgeSidebarEnabled && showMcpServerButton
|
||||
|
||||
const handleKnowledgeBaseSelect = useCallback(
|
||||
(bases?: KnowledgeBase[]) => {
|
||||
updateAssistant({ knowledge_bases: bases })
|
||||
setSelectedKnowledgeBases(bases ?? [])
|
||||
},
|
||||
[setSelectedKnowledgeBases, updateAssistant]
|
||||
)
|
||||
|
||||
// 仅允许在不含图片文件时mention非视觉模型
|
||||
const couldMentionNotVisionModel = useMemo(() => {
|
||||
return !files.some((file) => file.type === FileTypes.IMAGE)
|
||||
}, [files])
|
||||
|
||||
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 {
|
||||
logger.error('Cannot add non-vision model when images are uploaded')
|
||||
}
|
||||
},
|
||||
[couldMentionNotVisionModel, setMentionedModels]
|
||||
)
|
||||
|
||||
const onClearMentionModels = useCallback(() => setMentionedModels([]), [setMentionedModels])
|
||||
|
||||
const onEnableGenerateImage = useCallback(() => {
|
||||
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
|
||||
}, [assistant.enableGenerateImage, updateAssistant])
|
||||
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
const clearTopicShortcut = useShortcutDisplay('clear_topic')
|
||||
|
||||
const toggleToolVisibility = useCallback(
|
||||
(toolKey: string, isVisible: boolean | undefined) => {
|
||||
const newToolOrder = {
|
||||
@ -164,15 +204,8 @@ const InputbarTools = ({
|
||||
[dispatch, toolOrder.hidden, toolOrder.visible]
|
||||
)
|
||||
|
||||
const getQuickPanelMenuImpl = (params: {
|
||||
t: (key: string, options?: any) => string
|
||||
files: FileType[]
|
||||
couldAddImageFile: boolean
|
||||
text: string
|
||||
openSelectFileMenu: () => void
|
||||
translate: () => void
|
||||
}): QuickPanelListItem[] => {
|
||||
const { t, files, couldAddImageFile, text, openSelectFileMenu, translate } = params
|
||||
const getQuickPanelMenuImpl = (params: { text: string; translate: () => void }): QuickPanelListItem[] => {
|
||||
const { text, translate } = params
|
||||
|
||||
return [
|
||||
{
|
||||
@ -249,11 +282,13 @@ const InputbarTools = ({
|
||||
}
|
||||
},
|
||||
{
|
||||
label: couldAddImageFile ? t('chat.input.upload.label') : t('chat.input.upload.document'),
|
||||
label: couldAddImageFile ? t('chat.input.upload.attachment') : t('chat.input.upload.document'),
|
||||
description: '',
|
||||
icon: <Paperclip />,
|
||||
isMenu: true,
|
||||
action: openSelectFileMenu
|
||||
action: () => {
|
||||
attachmentButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('translate.title'),
|
||||
@ -313,15 +348,15 @@ const InputbarTools = ({
|
||||
title={t('chat.input.new_topic', { Command: newTopicShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={addNewTopic}>
|
||||
<ActionIconButton onClick={addNewTopic}>
|
||||
<MessageSquareDiff size={19} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'attachment',
|
||||
label: t('chat.input.upload.label'),
|
||||
label: t('chat.input.upload.image_or_document'),
|
||||
component: (
|
||||
<AttachmentButton
|
||||
ref={attachmentButtonRef}
|
||||
@ -329,28 +364,25 @@ const InputbarTools = ({
|
||||
extensions={extensions}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
ToolbarButton={ToolbarButton}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'thinking',
|
||||
label: t('chat.input.thinking.label'),
|
||||
component: (
|
||||
<ThinkingButton ref={thinkingButtonRef} model={model} assistant={assistant} ToolbarButton={ToolbarButton} />
|
||||
),
|
||||
component: <ThinkingButton ref={thinkingButtonRef} model={model} assistantId={assistant.id} />,
|
||||
condition: showThinkingButton
|
||||
},
|
||||
{
|
||||
key: 'web_search',
|
||||
label: t('chat.input.web_search.label'),
|
||||
component: <WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />,
|
||||
component: <WebSearchButton ref={webSearchButtonRef} assistantId={assistant.id} />,
|
||||
condition: !isMandatoryWebSearchModel(model)
|
||||
},
|
||||
{
|
||||
key: 'url_context',
|
||||
label: t('chat.input.url_context'),
|
||||
component: <UrlContextButton ref={urlContextButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />,
|
||||
component: <UrlContextButton ref={urlContextButtonRef} assistantId={assistant.id} />,
|
||||
condition: isGeminiModel(model) && isSupportUrlContextProvider(getProviderByModel(model))
|
||||
},
|
||||
{
|
||||
@ -361,36 +393,29 @@ const InputbarTools = ({
|
||||
ref={knowledgeBaseButtonRef}
|
||||
selectedBases={selectedKnowledgeBases}
|
||||
onSelect={handleKnowledgeBaseSelect}
|
||||
ToolbarButton={ToolbarButton}
|
||||
disabled={files.length > 0}
|
||||
/>
|
||||
),
|
||||
condition: showKnowledgeIcon
|
||||
condition: showKnowledgeBaseButton
|
||||
},
|
||||
{
|
||||
key: 'mcp_tools',
|
||||
label: t('settings.mcp.title'),
|
||||
component: (
|
||||
<MCPToolsButton
|
||||
assistant={assistant}
|
||||
assistantId={assistant.id}
|
||||
ref={mcpToolsButtonRef}
|
||||
ToolbarButton={ToolbarButton}
|
||||
setInputValue={setText}
|
||||
resizeTextArea={resizeTextArea}
|
||||
/>
|
||||
),
|
||||
condition: showMcpTools
|
||||
condition: showMcpServerButton
|
||||
},
|
||||
{
|
||||
key: 'generate_image',
|
||||
label: t('chat.input.generate_image'),
|
||||
component: (
|
||||
<GenerateImageButton
|
||||
model={model}
|
||||
assistant={assistant}
|
||||
onEnableGenerateImage={onEnableGenerateImage}
|
||||
ToolbarButton={ToolbarButton}
|
||||
/>
|
||||
<GenerateImageButton model={model} assistant={assistant} onEnableGenerateImage={onEnableGenerateImage} />
|
||||
),
|
||||
condition: isGenerateImageModel(model)
|
||||
},
|
||||
@ -400,10 +425,9 @@ const InputbarTools = ({
|
||||
component: (
|
||||
<MentionModelsButton
|
||||
ref={mentionModelsButtonRef}
|
||||
mentionedModels={mentionModels}
|
||||
mentionedModels={mentionedModels}
|
||||
onMentionModel={onMentionModel}
|
||||
onClearMentionModels={onClearMentionModels}
|
||||
ToolbarButton={ToolbarButton}
|
||||
couldMentionNotVisionModel={couldMentionNotVisionModel}
|
||||
files={files}
|
||||
setText={setText}
|
||||
@ -418,8 +442,7 @@ const InputbarTools = ({
|
||||
ref={quickPhrasesButtonRef}
|
||||
setInputValue={setText}
|
||||
resizeTextArea={resizeTextArea}
|
||||
ToolbarButton={ToolbarButton}
|
||||
assistantObj={assistant}
|
||||
assistantId={assistant.id}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@ -429,12 +452,12 @@ const InputbarTools = ({
|
||||
component: (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={t('chat.input.clear.label', { Command: cleanTopicShortcut })}
|
||||
title={t('chat.input.clear.label', { Command: clearTopicShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={clearTopic}>
|
||||
<ActionIconButton onClick={clearTopic}>
|
||||
<PaintbrushVertical size={18} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
@ -447,22 +470,22 @@ const InputbarTools = ({
|
||||
title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onToggleExpended}>
|
||||
<ActionIconButton onClick={onToggleExpended}>
|
||||
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'new_context',
|
||||
label: t('chat.input.new.context', { Command: '' }),
|
||||
component: <NewContextButton onNewContext={onNewContext} ToolbarButton={ToolbarButton} />
|
||||
component: <NewContextButton onNewContext={onNewContext} />
|
||||
}
|
||||
]
|
||||
}, [
|
||||
addNewTopic,
|
||||
assistant,
|
||||
cleanTopicShortcut,
|
||||
clearTopicShortcut,
|
||||
clearTopic,
|
||||
couldAddImageFile,
|
||||
couldMentionNotVisionModel,
|
||||
@ -470,7 +493,7 @@ const InputbarTools = ({
|
||||
files,
|
||||
handleKnowledgeBaseSelect,
|
||||
isExpended,
|
||||
mentionModels,
|
||||
mentionedModels,
|
||||
model,
|
||||
newTopicShortcut,
|
||||
onClearMentionModels,
|
||||
@ -482,8 +505,8 @@ const InputbarTools = ({
|
||||
selectedKnowledgeBases,
|
||||
setFiles,
|
||||
setText,
|
||||
showKnowledgeIcon,
|
||||
showMcpTools,
|
||||
showKnowledgeBaseButton,
|
||||
showMcpServerButton,
|
||||
showThinkingButton,
|
||||
t
|
||||
])
|
||||
@ -628,14 +651,14 @@ const InputbarTools = ({
|
||||
placement="top"
|
||||
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
|
||||
<ActionIconButton onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
|
||||
<CircleChevronRight
|
||||
size={18}
|
||||
style={{
|
||||
transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)'
|
||||
}}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ToolsContainer>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
@ -16,10 +17,9 @@ interface Props {
|
||||
selectedBases?: KnowledgeBase[]
|
||||
onSelect: (bases: KnowledgeBase[]) => void
|
||||
disabled?: boolean
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled, ToolbarButton }) => {
|
||||
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled }) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const quickPanel = useQuickPanel()
|
||||
@ -77,7 +77,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
quickPanel.open({
|
||||
title: t('chat.input.knowledge_base'),
|
||||
list: baseItems,
|
||||
symbol: '#',
|
||||
symbol: QuickPanelReservedSymbol.KnowledgeBase,
|
||||
multiple: true,
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
@ -86,7 +86,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
}, [baseItems, quickPanel, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '#') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
@ -95,7 +95,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
|
||||
// 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态
|
||||
useEffect(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '#') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
// 直接使用重新计算的 baseItems,因为它已经包含了最新的 isSelected 状态
|
||||
quickPanel.updateList(baseItems)
|
||||
}
|
||||
@ -107,12 +107,12 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel} disabled={disabled}>
|
||||
<FileSearch
|
||||
size={18}
|
||||
color={selectedBases && selectedBases.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
<ActionIconButton
|
||||
onClick={handleOpenQuickPanel}
|
||||
active={selectedBases && selectedBases.length > 0}
|
||||
disabled={disabled}>
|
||||
<FileSearch size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { isGeminiModel } from '@renderer/config/models'
|
||||
import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/config/providers'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
@ -6,7 +7,7 @@ import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { EventEmitter } from '@renderer/services/EventService'
|
||||
import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
|
||||
import { MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
|
||||
import { isToolUseModeFunction } from '@renderer/utils/assistant'
|
||||
import { Form, Input, Tooltip } from 'antd'
|
||||
import { CircleX, Hammer, Plus } from 'lucide-react'
|
||||
@ -21,11 +22,10 @@ export interface MCPToolsButtonRef {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
assistantId: string
|
||||
ref?: React.RefObject<MCPToolsButtonRef | null>
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
// 添加类型定义
|
||||
@ -113,14 +113,14 @@ const extractPromptContent = (response: any): string | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, ToolbarButton, ...props }) => {
|
||||
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assistantId }) => {
|
||||
const { activedMcpServers } = useMCPServers()
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const { updateAssistant, assistant } = useAssistant(props.assistant.id)
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
const model = assistant.model
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
@ -228,7 +228,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
quickPanel.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: menuItems,
|
||||
symbol: 'mcp',
|
||||
symbol: QuickPanelReservedSymbol.Mcp,
|
||||
multiple: true,
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
@ -377,7 +377,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
quickPanel.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: prompts,
|
||||
symbol: 'mcp-prompt',
|
||||
symbol: QuickPanelReservedSymbol.McpPrompt,
|
||||
multiple: true
|
||||
})
|
||||
}, [promptList, quickPanel, t])
|
||||
@ -465,13 +465,13 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
quickPanel.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: resourcesList,
|
||||
symbol: 'mcp-resource',
|
||||
symbol: QuickPanelReservedSymbol.McpResource,
|
||||
multiple: true
|
||||
})
|
||||
}, [resourcesList, quickPanel, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === 'mcp') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Mcp) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
@ -486,12 +486,9 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<Hammer
|
||||
size={18}
|
||||
color={assistant.mcpServers && assistant.mcpServers.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
<ActionIconButton onClick={handleOpenQuickPanel} active={assistant.mcpServers && assistant.mcpServers.length > 0}>
|
||||
<Hammer size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
|
||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelListItem } from '@renderer/components/QuickPanel/types'
|
||||
import { type QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { getModelLogo, isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
@ -27,7 +27,6 @@ interface Props {
|
||||
onClearMentionModels: () => void
|
||||
couldMentionNotVisionModel: boolean
|
||||
files: FileType[]
|
||||
ToolbarButton: any
|
||||
setText: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
@ -38,7 +37,6 @@ const MentionModelsButton: FC<Props> = ({
|
||||
onClearMentionModels,
|
||||
couldMentionNotVisionModel,
|
||||
files,
|
||||
ToolbarButton,
|
||||
setText
|
||||
}) => {
|
||||
const { providers } = useProviders()
|
||||
@ -242,7 +240,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
quickPanel.open({
|
||||
title: t('agents.edit.model.select.title'),
|
||||
list: modelItems,
|
||||
symbol: '@',
|
||||
symbol: QuickPanelReservedSymbol.MentionModels,
|
||||
multiple: true,
|
||||
triggerInfo: triggerInfo || { type: 'button' },
|
||||
afterAction({ item }) {
|
||||
@ -274,7 +272,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
)
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '@') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel({ type: 'button' })
|
||||
@ -286,7 +284,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
useEffect(() => {
|
||||
// 检查files是否变化
|
||||
if (filesRef.current !== files) {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '@') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||
quickPanel.close()
|
||||
}
|
||||
filesRef.current = files
|
||||
@ -295,7 +293,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
|
||||
// 监听 mentionedModels 变化,动态更新已打开的 QuickPanel 列表状态
|
||||
useEffect(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '@') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||
// 直接使用重新计算的 modelItems,因为它已经包含了最新的 isSelected 状态
|
||||
quickPanel.updateList(modelItems)
|
||||
}
|
||||
@ -307,9 +305,9 @@ const MentionModelsButton: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('agents.edit.model.select.title')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<AtSign size={18} color={mentionedModels.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'} />
|
||||
</ToolbarButton>
|
||||
<ActionIconButton onClick={handleOpenQuickPanel} active={mentionedModels.length > 0}>
|
||||
<AtSign size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Eraser } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
onNewContext: () => void
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
const NewContextButton: FC<Props> = ({ onNewContext, ToolbarButton }) => {
|
||||
const NewContextButton: FC<Props> = ({ onNewContext }) => {
|
||||
const newContextShortcut = useShortcutDisplay('toggle_new_context')
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -21,9 +20,9 @@ const NewContextButton: FC<Props> = ({ onNewContext, ToolbarButton }) => {
|
||||
title={t('chat.input.new.context', { Command: newContextShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onNewContext}>
|
||||
<ActionIconButton onClick={onNewContext}>
|
||||
<Eraser size={18} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import {
|
||||
type QuickPanelListItem,
|
||||
type QuickPanelOpenOptions,
|
||||
QuickPanelReservedSymbol
|
||||
} from '@renderer/components/QuickPanel'
|
||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelListItem, QuickPanelOpenOptions } from '@renderer/components/QuickPanel/types'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import QuickPhraseService from '@renderer/services/QuickPhraseService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { QuickPhrase } from '@renderer/types'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Input, Modal, Radio, Space, Tooltip } from 'antd'
|
||||
import { BotMessageSquare, Plus, Zap } from 'lucide-react'
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
@ -20,21 +23,16 @@ interface Props {
|
||||
ref?: React.RefObject<QuickPhrasesButtonRef | null>
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
ToolbarButton: any
|
||||
assistantObj: Assistant
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, assistantObj }: Props) => {
|
||||
const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }: Props) => {
|
||||
const [quickPhrasesList, setQuickPhrasesList] = useState<QuickPhrase[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [formData, setFormData] = useState({ title: '', content: '', location: 'global' })
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const activeAssistantId = useAppSelector(
|
||||
(state) =>
|
||||
state.assistants.assistants.find((a) => a.id === assistantObj.id)?.id || state.assistants.defaultAssistant.id
|
||||
)
|
||||
const { assistant, updateAssistant } = useAssistant(activeAssistantId)
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const loadQuickListPhrases = useCallback(
|
||||
@ -135,7 +133,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton,
|
||||
() => ({
|
||||
title: t('settings.quickPhrase.title'),
|
||||
list: phraseItems,
|
||||
symbol: 'quick-phrases'
|
||||
symbol: QuickPanelReservedSymbol.QuickPhrases
|
||||
}),
|
||||
[phraseItems, t]
|
||||
)
|
||||
@ -145,7 +143,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton,
|
||||
}, [quickPanel, quickPanelOpenOptions])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === 'quick-phrases') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.QuickPhrases) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
@ -159,9 +157,9 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton,
|
||||
return (
|
||||
<>
|
||||
<Tooltip placement="top" title={t('settings.quickPhrase.title')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<ActionIconButton onClick={handleOpenQuickPanel}>
|
||||
<Zap size={18} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Modal
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import {
|
||||
MdiLightbulbAutoOutline,
|
||||
MdiLightbulbOffOutline,
|
||||
@ -6,11 +7,11 @@ import {
|
||||
MdiLightbulbOn50,
|
||||
MdiLightbulbOn80
|
||||
} from '@renderer/components/Icons/SVGIcon'
|
||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { getThinkModelType, isDoubaoThinkingAutoModel, MODEL_SUPPORTED_OPTIONS } from '@renderer/config/models'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { getReasoningEffortOptionsLabel } from '@renderer/i18n/label'
|
||||
import { Assistant, Model, ThinkingOption } from '@renderer/types'
|
||||
import { Model, ThinkingOption } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { FC, ReactElement, useCallback, useImperativeHandle, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -22,14 +23,13 @@ export interface ThinkingButtonRef {
|
||||
interface Props {
|
||||
ref?: React.RefObject<ThinkingButtonRef | null>
|
||||
model: Model
|
||||
assistant: Assistant
|
||||
ToolbarButton: any
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): ReactElement => {
|
||||
const ThinkingButton: FC<Props> = ({ ref, model, assistantId }): ReactElement => {
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const { updateAssistantSettings } = useAssistant(assistant.id)
|
||||
const { assistant, updateAssistantSettings } = useAssistant(assistantId)
|
||||
|
||||
const currentReasoningEffort = useMemo(() => {
|
||||
return assistant.settings?.reasoning_effort || 'off'
|
||||
@ -49,27 +49,6 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
return MODEL_SUPPORTED_OPTIONS[modelType]
|
||||
}, [model, modelType])
|
||||
|
||||
const createThinkingIcon = useCallback((option?: ThinkingOption, isActive: boolean = false) => {
|
||||
const iconColor = isActive ? 'var(--color-primary)' : 'var(--color-icon)'
|
||||
|
||||
switch (true) {
|
||||
case option === 'minimal':
|
||||
return <MdiLightbulbOn30 width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
case option === 'low':
|
||||
return <MdiLightbulbOn50 width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
case option === 'medium':
|
||||
return <MdiLightbulbOn80 width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
case option === 'high':
|
||||
return <MdiLightbulbOn width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
case option === 'auto':
|
||||
return <MdiLightbulbAutoOutline width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
case option === 'off':
|
||||
return <MdiLightbulbOffOutline width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
default:
|
||||
return <MdiLightbulbOffOutline width={18} height={18} style={{ color: iconColor }} />
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onThinkingChange = useCallback(
|
||||
(option?: ThinkingOption) => {
|
||||
const isEnabled = option !== undefined && option !== 'off'
|
||||
@ -98,11 +77,11 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
level: option,
|
||||
label: getReasoningEffortOptionsLabel(option),
|
||||
description: '',
|
||||
icon: createThinkingIcon(option),
|
||||
icon: ThinkingIcon(option),
|
||||
isSelected: currentReasoningEffort === option,
|
||||
action: () => onThinkingChange(option)
|
||||
}))
|
||||
}, [createThinkingIcon, currentReasoningEffort, supportedOptions, onThinkingChange])
|
||||
}, [currentReasoningEffort, supportedOptions, onThinkingChange])
|
||||
|
||||
const isThinkingEnabled = currentReasoningEffort !== undefined && currentReasoningEffort !== 'off'
|
||||
|
||||
@ -114,12 +93,12 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
quickPanel.open({
|
||||
title: t('assistants.settings.reasoning_effort.label'),
|
||||
list: panelItems,
|
||||
symbol: 'thinking'
|
||||
symbol: QuickPanelReservedSymbol.Thinking
|
||||
})
|
||||
}, [quickPanel, panelItems, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === 'thinking') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Thinking) {
|
||||
quickPanel.close()
|
||||
return
|
||||
}
|
||||
@ -131,12 +110,6 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
openQuickPanel()
|
||||
}, [openQuickPanel, quickPanel, isThinkingEnabled, supportedOptions, disableThinking])
|
||||
|
||||
// 获取当前应显示的图标
|
||||
const getThinkingIcon = useCallback(() => {
|
||||
// 不再判断选项是否支持,依赖 useAssistant 更新选项为支持选项的行为
|
||||
return createThinkingIcon(currentReasoningEffort, currentReasoningEffort !== 'off')
|
||||
}, [createThinkingIcon, currentReasoningEffort])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
@ -151,11 +124,41 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
{getThinkingIcon()}
|
||||
</ToolbarButton>
|
||||
<ActionIconButton onClick={handleOpenQuickPanel} active={currentReasoningEffort !== 'off'}>
|
||||
{ThinkingIcon(currentReasoningEffort)}
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const ThinkingIcon = (option?: ThinkingOption) => {
|
||||
let IconComponent: React.FC<React.SVGProps<SVGSVGElement>> | null = null
|
||||
|
||||
switch (option) {
|
||||
case 'minimal':
|
||||
IconComponent = MdiLightbulbOn30
|
||||
break
|
||||
case 'low':
|
||||
IconComponent = MdiLightbulbOn50
|
||||
break
|
||||
case 'medium':
|
||||
IconComponent = MdiLightbulbOn80
|
||||
break
|
||||
case 'high':
|
||||
IconComponent = MdiLightbulbOn
|
||||
break
|
||||
case 'auto':
|
||||
IconComponent = MdiLightbulbAutoOutline
|
||||
break
|
||||
case 'off':
|
||||
IconComponent = MdiLightbulbOffOutline
|
||||
break
|
||||
default:
|
||||
IconComponent = MdiLightbulbOffOutline
|
||||
break
|
||||
}
|
||||
|
||||
return <IconComponent className="icon" width={18} height={18} style={{ marginTop: -2 }} />
|
||||
}
|
||||
|
||||
export default ThinkingButton
|
||||
|
||||
@ -11,7 +11,6 @@ type Props = {
|
||||
estimateTokenCount: number
|
||||
inputTokenCount: number
|
||||
contextCount: { current: number; max: number }
|
||||
ToolbarButton: any
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCount }) => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { isToolUseModeFunction } from '@renderer/utils/assistant'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Link } from 'lucide-react'
|
||||
@ -13,13 +13,12 @@ export interface UrlContextButtonRef {
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<UrlContextButtonRef | null>
|
||||
assistant: Assistant
|
||||
ToolbarButton: any
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const UrlContextButton: FC<Props> = ({ assistant, ToolbarButton }) => {
|
||||
const UrlContextButton: FC<Props> = ({ assistantId }) => {
|
||||
const { t } = useTranslation()
|
||||
const { updateAssistant } = useAssistant(assistant.id)
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const urlContentNewState = !assistant.enableUrlContext
|
||||
@ -48,14 +47,9 @@ const UrlContextButton: FC<Props> = ({ assistant, ToolbarButton }) => {
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.url_context')} arrow>
|
||||
<ToolbarButton type="text" onClick={handleToggle}>
|
||||
<Link
|
||||
size={18}
|
||||
style={{
|
||||
color: assistant.enableUrlContext ? 'var(--color-primary)' : 'var(--color-icon)'
|
||||
}}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
<ActionIconButton onClick={handleToggle} active={assistant.enableUrlContext}>
|
||||
<Link size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { BaiduOutlined, GoogleOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { BingLogo, BochaLogo, ExaLogo, SearXNGLogo, TavilyLogo, ZhipuLogo } from '@renderer/components/Icons'
|
||||
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { isGeminiModel, isWebSearchModel } from '@renderer/config/models'
|
||||
import { isGeminiWebSearchProvider } from '@renderer/config/providers'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
@ -9,7 +10,7 @@ import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { Assistant, WebSearchProvider, WebSearchProviderId } from '@renderer/types'
|
||||
import { WebSearchProvider, WebSearchProviderId } from '@renderer/types'
|
||||
import { hasObjectKey } from '@renderer/utils'
|
||||
import { isToolUseModeFunction } from '@renderer/utils/assistant'
|
||||
import { Tooltip } from 'antd'
|
||||
@ -23,17 +24,16 @@ export interface WebSearchButtonRef {
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<WebSearchButtonRef | null>
|
||||
assistant: Assistant
|
||||
ToolbarButton: any
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const logger = loggerService.withContext('WebSearchButton')
|
||||
|
||||
const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const { providers } = useWebSearchProviders()
|
||||
const { updateAssistant } = useAssistant(assistant.id)
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
// 注意:assistant.enableWebSearch 有不同的语义
|
||||
@ -44,24 +44,24 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
({ pid, size = 18, color }: { pid?: WebSearchProviderId; size?: number; color?: string }) => {
|
||||
switch (pid) {
|
||||
case 'bocha':
|
||||
return <BochaLogo width={size} height={size} color={color} />
|
||||
return <BochaLogo className="icon" width={size} height={size} color={color} />
|
||||
case 'exa':
|
||||
// size微调,视觉上和其他图标平衡一些
|
||||
return <ExaLogo width={size - 2} height={size} color={color} />
|
||||
return <ExaLogo className="icon" width={size - 2} height={size} color={color} />
|
||||
case 'tavily':
|
||||
return <TavilyLogo width={size} height={size} color={color} />
|
||||
return <TavilyLogo className="icon" width={size} height={size} color={color} />
|
||||
case 'zhipu':
|
||||
return <ZhipuLogo width={size} height={size} color={color} />
|
||||
return <ZhipuLogo className="icon" width={size} height={size} color={color} />
|
||||
case 'searxng':
|
||||
return <SearXNGLogo width={size} height={size} color={color} />
|
||||
return <SearXNGLogo className="icon" width={size} height={size} color={color} />
|
||||
case 'local-baidu':
|
||||
return <BaiduOutlined size={size} style={{ color, fontSize: size }} />
|
||||
case 'local-bing':
|
||||
return <BingLogo width={size} height={size} color={color} />
|
||||
return <BingLogo className="icon" width={size} height={size} color={color} />
|
||||
case 'local-google':
|
||||
return <GoogleOutlined size={size} style={{ color, fontSize: size }} />
|
||||
default:
|
||||
return <Globe size={size} style={{ color, fontSize: size }} />
|
||||
return <Globe className="icon" size={size} style={{ color, fontSize: size }} />
|
||||
}
|
||||
},
|
||||
[]
|
||||
@ -165,13 +165,13 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
quickPanel.open({
|
||||
title: t('chat.input.web_search.label'),
|
||||
list: providerItems,
|
||||
symbol: '?',
|
||||
symbol: QuickPanelReservedSymbol.WebSearch,
|
||||
pageSize: 9
|
||||
})
|
||||
}, [quickPanel, t, providerItems])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '?') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.WebSearch) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
@ -190,17 +190,15 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
const color = enableWebSearch ? 'var(--color-primary)' : 'var(--color-icon)'
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={enableWebSearch ? t('common.close') : t('chat.input.web_search.label')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onClick}>
|
||||
<WebSearchIcon color={color} pid={assistant.webSearchProviderId} />
|
||||
</ToolbarButton>
|
||||
<ActionIconButton onClick={onClick} active={!!enableWebSearch}>
|
||||
<WebSearchIcon pid={assistant.webSearchProviderId} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import CustomTag from '@renderer/components/Tags/CustomTag'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
||||
@ -25,7 +26,6 @@ import styled from 'styled-components'
|
||||
|
||||
import AttachmentButton, { AttachmentButtonRef } from '../Inputbar/AttachmentButton'
|
||||
import { FileNameRender, getFileIcon } from '../Inputbar/AttachmentPreview'
|
||||
import { ToolbarButton } from '../Inputbar/Inputbar'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
@ -346,27 +346,26 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
|
||||
setFiles={setFiles}
|
||||
couldAddImageFile={couldAddImageFile}
|
||||
extensions={extensions}
|
||||
ToolbarButton={ToolbarButton}
|
||||
/>
|
||||
)}
|
||||
</ActionBarLeft>
|
||||
<ActionBarMiddle />
|
||||
<ActionBarRight>
|
||||
<Tooltip title={t('common.cancel')}>
|
||||
<ToolbarButton type="text" onClick={onCancel}>
|
||||
<ActionIconButton onClick={onCancel}>
|
||||
<X size={16} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.save')}>
|
||||
<ToolbarButton type="text" onClick={handleSave}>
|
||||
<ActionIconButton onClick={handleSave}>
|
||||
<Save size={16} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
{message.role === 'user' && (
|
||||
<Tooltip title={t('chat.resend')}>
|
||||
<ToolbarButton type="text" onClick={handleResend}>
|
||||
<ActionIconButton onClick={handleResend}>
|
||||
<Send size={16} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ActionBarRight>
|
||||
|
||||
@ -46,8 +46,8 @@ const assistantsSlice = createSlice({
|
||||
removeAssistant: (state, action: PayloadAction<{ id: string }>) => {
|
||||
state.assistants = state.assistants.filter((c) => c.id !== action.payload.id)
|
||||
},
|
||||
updateAssistant: (state, action: PayloadAction<Assistant>) => {
|
||||
state.assistants = state.assistants.map((c) => (c.id === action.payload.id ? action.payload : c))
|
||||
updateAssistant: (state, action: PayloadAction<Partial<Assistant>>) => {
|
||||
state.assistants = state.assistants.map((c) => (c.id === action.payload.id ? { ...c, ...action.payload } : c))
|
||||
},
|
||||
updateAssistantSettings: (
|
||||
state,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user