feat(QuickPanel): Add new feature QuickPanel, unify input box operation. (#4356)
* feat(QuickPanel): Add new feature QuickPanel, unify input box operation. * refactor(Inputbar): Remove unused quickPanelSymbol reference and update navigation action in KnowledgeBaseButton * fix(Inputbar): Prevent translation action when input text is empty and reorder MentionModelsInput component * refactor(Inputbar): Add resizeTextArea prop to QuickPhrasesButton for better text area management * feat(i18n): Add translation strings for input actions and quick phrases in multiple languages * feat(Inputbar): Enhance AttachmentButton to support ref forwarding and quick panel opening * feat(i18n, Inputbar): Add upload file translation strings and enhance file count display in multiple languages * style(QuickPanel): Update background color for QuickPanelBody and add dark theme support * fix(Inputbar): Update upload label for vision model support * feat(QuickPanel): Add outside click handling and update close action type * feat(QuickPanel): Improve scrolling behavior with key press handling and add PageUp/PageDown support * feat(i18n): Add translation strings for menu description in multiple languages * refactor(QuickPhrasesButton): simplify phrase mapping by removing index-based disabling * fix(QuickPanel): correct regex pattern for search functionality * refactor(QuickPanel): remove searchText state and related logic for cleaner context management * refactor(QuickPanel): enhance search text handling and input management * refactor(Inputbar): update file name handling in AttachmentPreview and Inputbar components
This commit is contained in:
parent
ea059d5517
commit
f9be0e0d26
@ -9,6 +9,7 @@ interface CustomCollapseProps {
|
||||
|
||||
const CustomCollapse: FC<CustomCollapseProps> = ({ label, extra, children }) => {
|
||||
const CollapseStyle = {
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: '0.5px solid var(--color-border)'
|
||||
}
|
||||
|
||||
11
src/renderer/src/components/QuickPanel/hook.ts
Normal file
11
src/renderer/src/components/QuickPanel/hook.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { use } from 'react'
|
||||
|
||||
import { QuickPanelContext } from './provider'
|
||||
|
||||
export const useQuickPanel = () => {
|
||||
const context = use(QuickPanelContext)
|
||||
if (!context) {
|
||||
throw new Error('useQuickPanel must be used within a QuickPanelProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
4
src/renderer/src/components/QuickPanel/index.ts
Normal file
4
src/renderer/src/components/QuickPanel/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './hook'
|
||||
export * from './provider'
|
||||
export * from './types'
|
||||
export * from './view'
|
||||
83
src/renderer/src/components/QuickPanel/provider.tsx
Normal file
83
src/renderer/src/components/QuickPanel/provider.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import React, { createContext, useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
QuickPanelCallBackOptions,
|
||||
QuickPanelCloseAction,
|
||||
QuickPanelContextType,
|
||||
QuickPanelListItem,
|
||||
QuickPanelOpenOptions
|
||||
} from './types'
|
||||
|
||||
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
|
||||
|
||||
export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [symbol, setSymbol] = useState<string>('')
|
||||
|
||||
const [list, setList] = useState<QuickPanelListItem[]>([])
|
||||
const [title, setTitle] = useState<string | undefined>()
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(0)
|
||||
const [pageSize, setPageSize] = useState<number>(7)
|
||||
const [multiple, setMultiple] = useState<boolean>(false)
|
||||
const [onClose, setOnClose] = useState<
|
||||
((Options: Pick<QuickPanelCallBackOptions, 'symbol' | 'action'>) => void) | undefined
|
||||
>()
|
||||
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
|
||||
const open = useCallback((options: QuickPanelOpenOptions) => {
|
||||
setTitle(options.title)
|
||||
setList(options.list)
|
||||
setDefaultIndex(options.defaultIndex ?? 0)
|
||||
setPageSize(options.pageSize ?? 7)
|
||||
setMultiple(options.multiple ?? false)
|
||||
setSymbol(options.symbol)
|
||||
|
||||
setOnClose(() => options.onClose)
|
||||
setBeforeAction(() => options.beforeAction)
|
||||
setAfterAction(() => options.afterAction)
|
||||
|
||||
setIsVisible(true)
|
||||
}, [])
|
||||
|
||||
const close = useCallback(
|
||||
(action?: QuickPanelCloseAction) => {
|
||||
setIsVisible(false)
|
||||
onClose?.({ symbol, action })
|
||||
|
||||
setTimeout(() => {
|
||||
setList([])
|
||||
setOnClose(undefined)
|
||||
setBeforeAction(undefined)
|
||||
setAfterAction(undefined)
|
||||
setTitle(undefined)
|
||||
setSymbol('')
|
||||
}, 200)
|
||||
},
|
||||
[onClose, symbol]
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
|
||||
isVisible,
|
||||
symbol,
|
||||
|
||||
list,
|
||||
title,
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
}),
|
||||
[open, close, isVisible, symbol, list, title, defaultIndex, pageSize, multiple, onClose, beforeAction, afterAction]
|
||||
)
|
||||
|
||||
return <QuickPanelContext value={value}>{children}</QuickPanelContext>
|
||||
}
|
||||
|
||||
export { QuickPanelContext }
|
||||
66
src/renderer/src/components/QuickPanel/types.ts
Normal file
66
src/renderer/src/components/QuickPanel/types.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | string | undefined
|
||||
export type QuickPanelCallBackOptions = {
|
||||
symbol: string
|
||||
action: QuickPanelCloseAction
|
||||
item: QuickPanelListItem
|
||||
searchText?: string
|
||||
/** 是否处于多选状态 */
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
export type QuickPanelOpenOptions = {
|
||||
/** 显示在底部左边,类似于Placeholder */
|
||||
title?: string
|
||||
/** default: [] */
|
||||
list: QuickPanelListItem[]
|
||||
/** default: 0 */
|
||||
defaultIndex?: number
|
||||
/** default: 7 */
|
||||
pageSize?: number
|
||||
/** 是否支持按住cmd/ctrl键多选,default: false */
|
||||
multiple?: boolean
|
||||
/**
|
||||
* 用于标识是哪个快捷面板,不是用于触发显示
|
||||
* 可以是/@#符号,也可以是其他字符串
|
||||
*/
|
||||
symbol: string
|
||||
beforeAction?: (options: QuickPanelCallBackOptions) => void
|
||||
afterAction?: (options: QuickPanelCallBackOptions) => void
|
||||
onClose?: (options: QuickPanelCallBackOptions) => void
|
||||
}
|
||||
|
||||
export type QuickPanelListItem = {
|
||||
label: React.ReactNode | string
|
||||
description?: React.ReactNode | string
|
||||
/**
|
||||
* 由于title跟description可能是ReactNode,
|
||||
* 所以需要单独提供一个用于搜索过滤的文本,
|
||||
* 这个filterText可以是title跟description的字符串组合
|
||||
*/
|
||||
filterText?: string
|
||||
icon: React.ReactNode | string
|
||||
suffix?: React.ReactNode | string
|
||||
isSelected?: boolean
|
||||
isMenu?: boolean
|
||||
disabled?: boolean
|
||||
action?: (options: QuickPanelCallBackOptions) => void
|
||||
}
|
||||
|
||||
// 定义上下文类型
|
||||
export interface QuickPanelContextType {
|
||||
readonly open: (options: QuickPanelOpenOptions) => void
|
||||
readonly close: (action?: QuickPanelCloseAction) => void
|
||||
readonly isVisible: boolean
|
||||
readonly symbol: string
|
||||
readonly list: QuickPanelListItem[]
|
||||
readonly title?: string
|
||||
readonly defaultIndex: number
|
||||
readonly pageSize: number
|
||||
readonly multiple: boolean
|
||||
|
||||
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly afterAction?: (Options: QuickPanelCallBackOptions) => void
|
||||
}
|
||||
566
src/renderer/src/components/QuickPanel/view.tsx
Normal file
566
src/renderer/src/components/QuickPanel/view.tsx
Normal file
@ -0,0 +1,566 @@
|
||||
import { CheckOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import React, { use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { QuickPanelContext } from './provider'
|
||||
import { QuickPanelCallBackOptions, QuickPanelCloseAction, QuickPanelListItem, QuickPanelOpenOptions } from './types'
|
||||
|
||||
/**
|
||||
* @description 快捷面板内容视图;
|
||||
* 请不要往这里添加入参,避免耦合;
|
||||
* 这里只读取来自上下文QuickPanelContext的数据
|
||||
*
|
||||
* 无奈之举,为了清除输入框搜索文本,所以传了个setInputText进来
|
||||
*/
|
||||
export const QuickPanelView: React.FC<{
|
||||
setInputText: React.Dispatch<React.SetStateAction<string>>
|
||||
}> = ({ setInputText }) => {
|
||||
const ctx = use(QuickPanelContext)
|
||||
if (!ctx) {
|
||||
throw new Error('QuickPanel must be used within a QuickPanelProvider')
|
||||
}
|
||||
|
||||
const ASSISTIVE_KEY = isMac ? '⌘' : 'Ctrl'
|
||||
const [isAssistiveKeyPressed, setIsAssistiveKeyPressed] = useState(false)
|
||||
// 避免上下翻页时,鼠标干扰
|
||||
const [isMouseOver, setIsMouseOver] = useState(false)
|
||||
|
||||
const [index, setIndex] = useState(ctx.defaultIndex)
|
||||
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
|
||||
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const scrollBlock = useRef<ScrollLogicalPosition>('nearest')
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const searchTextRef = useRef('')
|
||||
|
||||
// 解决长按上下键时滚动太慢问题
|
||||
const keyPressCount = useRef<number>(0)
|
||||
const scrollBehavior = useRef<'auto' | 'smooth'>('smooth')
|
||||
|
||||
// 处理搜索,过滤列表
|
||||
const list = useMemo(() => {
|
||||
if (!ctx.isVisible && !ctx.symbol) return []
|
||||
const newList = ctx.list?.filter((item) => {
|
||||
const _searchText = searchText.replace(/^[/@]/, '')
|
||||
if (!_searchText) return true
|
||||
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
return filterText.toLowerCase().includes(_searchText.toLowerCase())
|
||||
})
|
||||
|
||||
setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1)
|
||||
|
||||
return newList
|
||||
}, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText])
|
||||
|
||||
const canForwardAndBackward = useMemo(() => {
|
||||
return list.some((item) => item.isMenu) || historyPanel.length > 0
|
||||
}, [list, historyPanel])
|
||||
|
||||
const clearSearchText = useCallback(
|
||||
(includeSymbol = false) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
const cursorPosition = textArea.selectionStart ?? 0
|
||||
const prevChar = textArea.value[cursorPosition - 1]
|
||||
if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) {
|
||||
searchTextRef.current = prevChar
|
||||
}
|
||||
|
||||
const _searchText = includeSymbol ? searchTextRef.current : searchTextRef.current.replace(/^[/@]/, '')
|
||||
if (!_searchText) return
|
||||
|
||||
const inputText = textArea.value
|
||||
let newText = inputText
|
||||
const searchPattern = new RegExp(`${_searchText}$`)
|
||||
|
||||
const match = inputText.slice(0, cursorPosition).match(searchPattern)
|
||||
if (match) {
|
||||
const start = match.index || 0
|
||||
const end = start + match[0].length
|
||||
newText = inputText.slice(0, start) + inputText.slice(end)
|
||||
setInputText(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(start, start)
|
||||
}, 0)
|
||||
}
|
||||
setSearchText('')
|
||||
},
|
||||
[setInputText]
|
||||
)
|
||||
|
||||
const handleClose = useCallback(
|
||||
(action?: QuickPanelCloseAction) => {
|
||||
ctx.close(action)
|
||||
setHistoryPanel([])
|
||||
|
||||
if (action === 'delete-symbol') {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
if (textArea) {
|
||||
setInputText(textArea.value)
|
||||
}
|
||||
} else if (action && !['outsideclick', 'esc'].includes(action)) {
|
||||
clearSearchText(true)
|
||||
}
|
||||
},
|
||||
[ctx, clearSearchText, setInputText]
|
||||
)
|
||||
|
||||
const handleItemAction = useCallback(
|
||||
(item: QuickPanelListItem, action?: QuickPanelCloseAction) => {
|
||||
if (item.disabled) return
|
||||
|
||||
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
|
||||
symbol: ctx.symbol,
|
||||
action,
|
||||
item,
|
||||
searchText: searchText,
|
||||
multiple: isAssistiveKeyPressed
|
||||
}
|
||||
ctx.beforeAction?.(quickPanelCallBackOptions)
|
||||
item?.action?.(quickPanelCallBackOptions)
|
||||
ctx.afterAction?.(quickPanelCallBackOptions)
|
||||
|
||||
if (item.isMenu) {
|
||||
// 保存上一个打开的选项,用于回退
|
||||
setHistoryPanel((prev) => [
|
||||
...(prev || []),
|
||||
{
|
||||
title: ctx.title,
|
||||
list: ctx.list,
|
||||
symbol: ctx.symbol,
|
||||
multiple: ctx.multiple,
|
||||
defaultIndex: index,
|
||||
pageSize: ctx.pageSize,
|
||||
onClose: ctx.onClose,
|
||||
beforeAction: ctx.beforeAction,
|
||||
afterAction: ctx.afterAction
|
||||
}
|
||||
])
|
||||
clearSearchText(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.multiple && isAssistiveKeyPressed) return
|
||||
|
||||
handleClose(action)
|
||||
},
|
||||
[ctx, searchText, isAssistiveKeyPressed, handleClose, clearSearchText, index]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
searchTextRef.current = searchText
|
||||
}, [searchText])
|
||||
|
||||
// 获取当前输入的搜索词
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) return
|
||||
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
const cursorPosition = target.selectionStart
|
||||
const textBeforeCursor = target.value.slice(0, cursorPosition)
|
||||
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||
const lastSymbolIndex = Math.max(lastSlashIndex, lastAtIndex)
|
||||
|
||||
if (lastSymbolIndex !== -1) {
|
||||
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
|
||||
setSearchText(newSearchText)
|
||||
} else {
|
||||
handleClose('delete-symbol')
|
||||
}
|
||||
}
|
||||
|
||||
textArea.addEventListener('input', handleInput)
|
||||
|
||||
return () => {
|
||||
textArea.removeEventListener('input', handleInput)
|
||||
setSearchText('')
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctx.isVisible])
|
||||
|
||||
// 处理上下翻时滚动到选中的元素
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return
|
||||
|
||||
const selectedElement = contentRef.current.children[index] as HTMLElement
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: scrollBlock.current,
|
||||
behavior: scrollBehavior.current
|
||||
})
|
||||
scrollBlock.current = 'nearest'
|
||||
}
|
||||
}, [index])
|
||||
|
||||
// 处理键盘事件
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isMac ? e.metaKey : e.ctrlKey) {
|
||||
setIsAssistiveKeyPressed(true)
|
||||
}
|
||||
|
||||
// 处理上下翻页时,滚动太慢问题
|
||||
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
keyPressCount.current++
|
||||
if (keyPressCount.current > 5) {
|
||||
scrollBehavior.current = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
}
|
||||
if (['ArrowLeft', 'ArrowRight'].includes(e.key) && isAssistiveKeyPressed) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
if (isAssistiveKeyPressed) {
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev - ctx.pageSize
|
||||
if (prev === 0) return list.length - 1
|
||||
return newIndex < 0 ? 0 : newIndex
|
||||
})
|
||||
} else {
|
||||
scrollBlock.current = 'nearest'
|
||||
setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1))
|
||||
}
|
||||
break
|
||||
|
||||
case 'ArrowDown':
|
||||
if (isAssistiveKeyPressed) {
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev + ctx.pageSize
|
||||
if (prev + 1 === list.length) return 0
|
||||
return newIndex >= list.length ? list.length - 1 : newIndex
|
||||
})
|
||||
} else {
|
||||
scrollBlock.current = 'nearest'
|
||||
setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0))
|
||||
}
|
||||
break
|
||||
|
||||
case 'PageUp':
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev - ctx.pageSize
|
||||
return newIndex < 0 ? 0 : newIndex
|
||||
})
|
||||
break
|
||||
|
||||
case 'PageDown':
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev + ctx.pageSize
|
||||
return newIndex >= list.length ? list.length - 1 : newIndex
|
||||
})
|
||||
break
|
||||
|
||||
case 'ArrowLeft':
|
||||
if (!isAssistiveKeyPressed) return
|
||||
if (!historyPanel.length) return
|
||||
clearSearchText(false)
|
||||
if (historyPanel.length > 0) {
|
||||
const lastPanel = historyPanel.pop()
|
||||
if (lastPanel) {
|
||||
ctx.open(lastPanel)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'ArrowRight':
|
||||
if (!isAssistiveKeyPressed) return
|
||||
if (!list?.[index]?.isMenu) return
|
||||
clearSearchText(false)
|
||||
handleItemAction(list[index], 'enter')
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
if (list?.[index]) {
|
||||
handleItemAction(list[index], 'enter')
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
handleClose('esc')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (isMac ? !e.metaKey : !e.ctrlKey) {
|
||||
setIsAssistiveKeyPressed(false)
|
||||
}
|
||||
|
||||
keyPressCount.current = 0
|
||||
scrollBehavior.current = 'smooth'
|
||||
}
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('#inputbar')) return
|
||||
if (bodyRef.current && !bodyRef.current.contains(target)) {
|
||||
handleClose('outsideclick')
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [index, isAssistiveKeyPressed, historyPanel, ctx, list, handleItemAction, handleClose, clearSearchText])
|
||||
|
||||
return (
|
||||
<QuickPanelContainer $pageSize={ctx.pageSize} className={ctx.isVisible ? 'visible' : ''}>
|
||||
<QuickPanelBody ref={bodyRef} onMouseMove={() => setIsMouseOver(true)}>
|
||||
<QuickPanelContent ref={contentRef} $pageSize={ctx.pageSize} $isMouseOver={isMouseOver}>
|
||||
{list.map((item, i) => (
|
||||
<QuickPanelItem
|
||||
className={classNames({
|
||||
focused: i === index,
|
||||
selected: item.isSelected,
|
||||
disabled: item.disabled
|
||||
})}
|
||||
key={i}
|
||||
onClick={() => handleItemAction(item, 'click')}
|
||||
onMouseEnter={() => setIndex(i)}>
|
||||
<QuickPanelItemLeft>
|
||||
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
|
||||
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
|
||||
</QuickPanelItemLeft>
|
||||
|
||||
<QuickPanelItemRight>
|
||||
{item.description && <QuickPanelItemDescription>{item.description}</QuickPanelItemDescription>}
|
||||
<QuickPanelItemSuffixIcon>
|
||||
{item.suffix ? (
|
||||
item.suffix
|
||||
) : item.isSelected ? (
|
||||
<CheckOutlined />
|
||||
) : (
|
||||
item.isMenu && !item.disabled && <RightOutlined />
|
||||
)}
|
||||
</QuickPanelItemSuffixIcon>
|
||||
</QuickPanelItemRight>
|
||||
</QuickPanelItem>
|
||||
))}
|
||||
</QuickPanelContent>
|
||||
<QuickPanelFooter>
|
||||
<QuickPanelFooterTips>
|
||||
<QuickPanelTitle>{ctx.title || ''}</QuickPanelTitle>
|
||||
<Flex align="center" gap={16}>
|
||||
<span>ESC {t('settings.quickPanel.close')}</span>
|
||||
|
||||
<Flex align="center" gap={4}>
|
||||
▲▼ {t('settings.quickPanel.select')}
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" gap={4}>
|
||||
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
|
||||
{ASSISTIVE_KEY}
|
||||
</span>
|
||||
+ ▲▼ {t('settings.quickPanel.page')}
|
||||
</Flex>
|
||||
|
||||
{canForwardAndBackward && (
|
||||
<Flex align="center" gap={4}>
|
||||
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
|
||||
{ASSISTIVE_KEY}
|
||||
</span>
|
||||
+ ◀︎▶︎ {t('settings.quickPanel.back')}/{t('settings.quickPanel.forward')}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex align="center" gap={4}>
|
||||
↩︎ {t('settings.quickPanel.confirm')}
|
||||
</Flex>
|
||||
|
||||
{ctx.multiple && (
|
||||
<Flex align="center" gap={4}>
|
||||
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
|
||||
{ASSISTIVE_KEY}
|
||||
</span>
|
||||
+ ↩︎ {t('settings.quickPanel.multiple')}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</QuickPanelFooterTips>
|
||||
</QuickPanelFooter>
|
||||
</QuickPanelBody>
|
||||
</QuickPanelContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const QuickPanelContainer = styled.div<{ $pageSize: number }>`
|
||||
--focused-color: rgba(0, 0, 0, 0.06);
|
||||
--selected-color: rgba(0, 0, 0, 0.03);
|
||||
max-height: 0;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
padding: 0 30px 0 30px;
|
||||
transform: translateY(-100%);
|
||||
transform-origin: bottom;
|
||||
transition: max-height 0.2s ease;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
&.visible {
|
||||
pointer-events: auto;
|
||||
max-height: ${(props) => props.$pageSize * 31 + 100}px;
|
||||
}
|
||||
body[theme-mode='dark'] & {
|
||||
--focused-color: rgba(255, 255, 255, 0.1);
|
||||
--selected-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelBody = styled.div`
|
||||
background-color: rgba(240, 240, 240, 0.5);
|
||||
backdrop-filter: blur(35px) saturate(150%);
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 5px 0;
|
||||
border-width: 0.5px 0.5px 0 0.5px;
|
||||
border-style: solid;
|
||||
border-color: var(--color-border);
|
||||
body[theme-mode='dark'] & {
|
||||
background-color: rgba(40, 40, 40, 0.4);
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelFooter = styled.div`
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const QuickPanelFooterTips = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-3);
|
||||
padding: 8px 12px 5px;
|
||||
`
|
||||
|
||||
const QuickPanelTitle = styled.div`
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const QuickPanelContent = styled.div<{ $pageSize: number; $isMouseOver: boolean }>`
|
||||
width: 100%;
|
||||
max-height: ${(props) => props.$pageSize * 31}px;
|
||||
padding: 0 5px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
pointer-events: ${(props) => (props.$isMouseOver ? 'auto' : 'none')};
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelItem = styled.div`
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
justify-content: space-between;
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
margin-bottom: 1px;
|
||||
&.selected {
|
||||
background-color: var(--selected-color);
|
||||
}
|
||||
&.focused {
|
||||
background-color: var(--focused-color);
|
||||
}
|
||||
&.disabled {
|
||||
--selected-color: rgba(0, 0, 0, 0.02);
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelItemLeft = styled.div`
|
||||
max-width: 60%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const QuickPanelItemIcon = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const QuickPanelItemLabel = styled.span`
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const QuickPanelItemRight = styled.div`
|
||||
min-width: 20%;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const QuickPanelItemDescription = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const QuickPanelItemSuffixIcon = styled.span`
|
||||
min-width: 12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 3px;
|
||||
`
|
||||
@ -1,7 +1,8 @@
|
||||
import { FileType, KnowledgeItem, Topic, TranslateHistory } from '@renderer/types'
|
||||
import { FileType, KnowledgeItem, QuickPhrase, Topic, TranslateHistory } from '@renderer/types'
|
||||
import { Dexie, type EntityTable } from 'dexie'
|
||||
|
||||
import { upgradeToV5 } from './upgrades'
|
||||
|
||||
// Database declaration (move this to its own module also)
|
||||
export const db = new Dexie('CherryStudio') as Dexie & {
|
||||
files: EntityTable<FileType, 'id'>
|
||||
@ -9,6 +10,7 @@ export const db = new Dexie('CherryStudio') as Dexie & {
|
||||
settings: EntityTable<{ id: string; value: any }, 'id'>
|
||||
knowledge_notes: EntityTable<KnowledgeItem, 'id'>
|
||||
translate_history: EntityTable<TranslateHistory, 'id'>
|
||||
quick_phrases: EntityTable<QuickPhrase, 'id'>
|
||||
}
|
||||
|
||||
db.version(1).stores({
|
||||
@ -42,7 +44,8 @@ db.version(5)
|
||||
topics: '&id, messages',
|
||||
settings: '&id, value',
|
||||
knowledge_notes: '&id, baseId, type, content, created_at, updated_at',
|
||||
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt'
|
||||
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
|
||||
quick_phrases: 'id'
|
||||
})
|
||||
.upgrade((tx) => upgradeToV5(tx))
|
||||
|
||||
|
||||
@ -235,7 +235,9 @@
|
||||
"topics.export.siyuan": "Export to Siyuan Note",
|
||||
"topics.export.wait_for_title_naming": "Generating title...",
|
||||
"topics.export.title_naming_success": "Title generated successfully",
|
||||
"topics.export.title_naming_failed": "Failed to generate title, using default title"
|
||||
"topics.export.title_naming_failed": "Failed to generate title, using default title",
|
||||
"input.translating": "Translating...",
|
||||
"input.upload.upload_from_local": "Upload local file..."
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Collapse",
|
||||
@ -328,7 +330,7 @@
|
||||
"files": {
|
||||
"actions": "Actions",
|
||||
"all": "All Files",
|
||||
"count": "Count",
|
||||
"count": "files",
|
||||
"created_at": "Created At",
|
||||
"delete": "Delete",
|
||||
"delete.content": "Deleting a file will delete its reference from all messages. Are you sure you want to delete this file?",
|
||||
@ -1063,7 +1065,8 @@
|
||||
"deleteServerConfirm": "Are you sure you want to delete this server?",
|
||||
"registry": "Package Registry",
|
||||
"registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.",
|
||||
"registryDefault": "Default"
|
||||
"registryDefault": "Default",
|
||||
"not_support": "Model not supported"
|
||||
},
|
||||
"messages.divider": "Show divider between messages",
|
||||
"messages.grid_columns": "Message grid display columns",
|
||||
@ -1261,6 +1264,27 @@
|
||||
"title": "Tavily"
|
||||
},
|
||||
"title": "Web Search"
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "Quick Phrases",
|
||||
"add": "Add Phrase",
|
||||
"edit": "Edit Phrase",
|
||||
"titleLabel": "Title",
|
||||
"contentLabel": "Content",
|
||||
"titlePlaceholder": "Please enter phrase title",
|
||||
"contentPlaceholder": "Please enter phrase content, support using variables, and press Tab to quickly locate the variable to modify. For example: \nHelp me plan a route from ${from} to ${to}, and send it to ${email}.",
|
||||
"delete": "Delete Phrase",
|
||||
"deleteConfirm": "The phrase cannot be recovered after deletion, continue?"
|
||||
},
|
||||
"quickPanel": {
|
||||
"title": "Quick Menu",
|
||||
"close": "Close",
|
||||
"select": "Select",
|
||||
"page": "Page",
|
||||
"confirm": "Confirm",
|
||||
"back": "Back",
|
||||
"forward": "Forward",
|
||||
"multiple": "Multiple Select"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
@ -1286,7 +1310,10 @@
|
||||
"scroll_sync.disable": "Disable synced scroll",
|
||||
"scroll_sync.enable": "Enable synced scroll",
|
||||
"title": "Translation",
|
||||
"tooltip.newline": "Newline"
|
||||
"tooltip.newline": "Newline",
|
||||
"menu": {
|
||||
"description": "Translate the content of the current input box"
|
||||
}
|
||||
},
|
||||
"tray": {
|
||||
"quit": "Quit",
|
||||
|
||||
@ -235,7 +235,9 @@
|
||||
"topics.export.siyuan": "思源笔记にエクスポート",
|
||||
"topics.export.wait_for_title_naming": "タイトルを生成中...",
|
||||
"topics.export.title_naming_success": "タイトルの生成に成功しました",
|
||||
"topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します"
|
||||
"topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します",
|
||||
"input.translating": "翻訳中...",
|
||||
"input.upload.upload_from_local": "ローカルファイルをアップロード..."
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折りたたむ",
|
||||
@ -328,7 +330,7 @@
|
||||
"files": {
|
||||
"actions": "操作",
|
||||
"all": "すべてのファイル",
|
||||
"count": "数",
|
||||
"count": "ファイル",
|
||||
"created_at": "作成日",
|
||||
"delete": "削除",
|
||||
"delete.content": "ファイルを削除すると、ファイルがすべてのメッセージで参照されることを削除します。このファイルを削除してもよろしいですか?",
|
||||
@ -1062,7 +1064,8 @@
|
||||
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?",
|
||||
"registry": "パッケージ管理レジストリ",
|
||||
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
|
||||
"registryDefault": "デフォルト"
|
||||
"registryDefault": "デフォルト",
|
||||
"not_support": "モデルはサポートされていません"
|
||||
},
|
||||
"messages.divider": "メッセージ間に区切り線を表示",
|
||||
"messages.grid_columns": "メッセージグリッドの表示列数",
|
||||
@ -1261,7 +1264,28 @@
|
||||
},
|
||||
"title": "ウェブ検索"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新チェックを有効にする"
|
||||
"general.auto_check_update.title": "自動更新チェックを有効にする",
|
||||
"quickPhrase": {
|
||||
"title": "クイックフレーズ",
|
||||
"add": "フレーズを追加",
|
||||
"edit": "フレーズを編集",
|
||||
"titleLabel": "タイトル",
|
||||
"contentLabel": "内容",
|
||||
"titlePlaceholder": "フレーズのタイトルを入力してください",
|
||||
"contentPlaceholder": "フレーズの内容を入力してください。変数を使用することもできます。変数を使用する場合は、Tabキーを押して変数を選択し、変数を変更してください。例:\n私の名前は${name}です。",
|
||||
"delete": "フレーズを削除",
|
||||
"deleteConfirm": "削除後は復元できません。続行しますか?"
|
||||
},
|
||||
"quickPanel": {
|
||||
"title": "クイックメニュー",
|
||||
"close": "閉じる",
|
||||
"select": "選択",
|
||||
"page": "ページ",
|
||||
"confirm": "確認",
|
||||
"back": "戻る",
|
||||
"forward": "進む",
|
||||
"multiple": "複数選択"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意の言語",
|
||||
@ -1286,7 +1310,10 @@
|
||||
"scroll_sync.disable": "關閉滾動同步",
|
||||
"scroll_sync.enable": "開啟滾動同步",
|
||||
"title": "翻訳",
|
||||
"tooltip.newline": "改行"
|
||||
"tooltip.newline": "改行",
|
||||
"menu": {
|
||||
"description": "對當前輸入框內容進行翻譯"
|
||||
}
|
||||
},
|
||||
"tray": {
|
||||
"quit": "終了",
|
||||
|
||||
@ -235,7 +235,9 @@
|
||||
"topics.export.siyuan": "Экспорт в Siyuan Note",
|
||||
"topics.export.wait_for_title_naming": "Создание заголовка...",
|
||||
"topics.export.title_naming_success": "Заголовок успешно создан",
|
||||
"topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию"
|
||||
"topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию",
|
||||
"input.translating": "Перевод...",
|
||||
"input.upload.upload_from_local": "Загрузить локальный файл..."
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Свернуть",
|
||||
@ -328,7 +330,7 @@
|
||||
"files": {
|
||||
"actions": "Действия",
|
||||
"all": "Все файлы",
|
||||
"count": "Количество",
|
||||
"count": "файлов",
|
||||
"created_at": "Дата создания",
|
||||
"delete": "Удалить",
|
||||
"delete.content": "Удаление файла удалит его из всех сообщений, вы уверены, что хотите удалить этот файл?",
|
||||
@ -1062,7 +1064,8 @@
|
||||
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",
|
||||
"registry": "Реестр пакетов",
|
||||
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
|
||||
"registryDefault": "По умолчанию"
|
||||
"registryDefault": "По умолчанию",
|
||||
"not_support": "Модель не поддерживается"
|
||||
},
|
||||
"messages.divider": "Показывать разделитель между сообщениями",
|
||||
"messages.grid_columns": "Количество столбцов сетки сообщений",
|
||||
@ -1261,7 +1264,28 @@
|
||||
},
|
||||
"title": "Поиск в Интернете"
|
||||
},
|
||||
"general.auto_check_update.title": "Включить автоматическую проверку обновлений"
|
||||
"general.auto_check_update.title": "Включить автоматическую проверку обновлений",
|
||||
"quickPhrase": {
|
||||
"title": "Быстрые фразы",
|
||||
"add": "Добавить фразу",
|
||||
"edit": "Редактировать фразу",
|
||||
"titleLabel": "Заголовок",
|
||||
"contentLabel": "Содержание",
|
||||
"titlePlaceholder": "Введите заголовок фразы",
|
||||
"contentPlaceholder": "Введите содержание фразы, поддерживает использование переменных, и нажмите Tab для быстрого перехода к переменной для изменения. Например: \nПомоги мне спланировать маршрут от ${from} до ${to} и отправить его на ${email}.",
|
||||
"delete": "Удалить фразу",
|
||||
"deleteConfirm": "После удаления фраза не может быть восстановлена, продолжить?"
|
||||
},
|
||||
"quickPanel": {
|
||||
"title": "Быстрое меню",
|
||||
"close": "Закрыть",
|
||||
"select": "Выбрать",
|
||||
"page": "Страница",
|
||||
"confirm": "Подтвердить",
|
||||
"back": "Назад",
|
||||
"forward": "Вперед",
|
||||
"multiple": "Множественный выбор"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "Любой язык",
|
||||
@ -1286,7 +1310,10 @@
|
||||
"scroll_sync.disable": "Отключить синхронизацию прокрутки",
|
||||
"scroll_sync.enable": "Включить синхронизацию прокрутки",
|
||||
"title": "Перевод",
|
||||
"tooltip.newline": "Перевести"
|
||||
"tooltip.newline": "Перевести",
|
||||
"menu": {
|
||||
"description": "Перевести содержимое текущего ввода"
|
||||
}
|
||||
},
|
||||
"tray": {
|
||||
"quit": "Выйти",
|
||||
|
||||
@ -129,11 +129,13 @@
|
||||
"input.new_topic": "新话题 {{Command}}",
|
||||
"input.pause": "暂停",
|
||||
"input.placeholder": "在这里输入消息...",
|
||||
"input.translating": "翻译中...",
|
||||
"input.send": "发送",
|
||||
"input.settings": "设置",
|
||||
"input.topics": " 话题 ",
|
||||
"input.translate": "翻译成{{target_language}}",
|
||||
"input.upload": "上传图片或文档",
|
||||
"input.upload.upload_from_local": "上传本地文件...",
|
||||
"input.upload.document": "上传文档(模型不支持图片)",
|
||||
"input.web_search": "开启网络搜索",
|
||||
"input.web_search.button.ok": "去设置",
|
||||
@ -328,7 +330,7 @@
|
||||
"files": {
|
||||
"actions": "操作",
|
||||
"all": "所有文件",
|
||||
"count": "文件数",
|
||||
"count": "个文件",
|
||||
"created_at": "创建时间",
|
||||
"delete": "删除",
|
||||
"delete.content": "删除文件会删除文件在所有消息中的引用,确定要删除此文件吗?",
|
||||
@ -1063,7 +1065,8 @@
|
||||
"deleteServerConfirm": "确定要删除此服务器吗?",
|
||||
"registry": "包管理源",
|
||||
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题。",
|
||||
"registryDefault": "默认"
|
||||
"registryDefault": "默认",
|
||||
"not_support": "模型不支持"
|
||||
},
|
||||
"messages.divider": "消息分割线",
|
||||
"messages.grid_columns": "消息网格展示列数",
|
||||
@ -1261,6 +1264,27 @@
|
||||
"title": "Tavily"
|
||||
},
|
||||
"title": "网络搜索"
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "快捷短语",
|
||||
"add": "添加短语",
|
||||
"edit": "编辑短语",
|
||||
"titleLabel": "标题",
|
||||
"contentLabel": "内容",
|
||||
"titlePlaceholder": "请输入短语标题",
|
||||
"contentPlaceholder": "请输入短语内容,支持使用变量,然后按Tab键可以快速定位到变量进行修改。比如:\n帮我规划从${from}到${to}的路线,然后发送到${email}。",
|
||||
"delete": "删除短语",
|
||||
"deleteConfirm": "删除短语后将无法恢复,是否继续?"
|
||||
},
|
||||
"quickPanel": {
|
||||
"title": "快捷菜单",
|
||||
"close": "关闭",
|
||||
"select": "选择",
|
||||
"page": "翻页",
|
||||
"confirm": "确认",
|
||||
"back": "后退",
|
||||
"forward": "前进",
|
||||
"multiple": "多选"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
@ -1280,6 +1304,9 @@
|
||||
"empty": "暂无翻译历史",
|
||||
"title": "翻译历史"
|
||||
},
|
||||
"menu": {
|
||||
"description": "对当前输入框内容进行翻译"
|
||||
},
|
||||
"input.placeholder": "输入文本进行翻译",
|
||||
"output.placeholder": "翻译",
|
||||
"processing": "翻译中...",
|
||||
|
||||
@ -235,7 +235,9 @@
|
||||
"topics.export.siyuan": "匯出到思源筆記",
|
||||
"topics.export.wait_for_title_naming": "正在生成標題...",
|
||||
"topics.export.title_naming_success": "標題生成成功",
|
||||
"topics.export.title_naming_failed": "標題生成失敗,使用預設標題"
|
||||
"topics.export.title_naming_failed": "標題生成失敗,使用預設標題",
|
||||
"input.translating": "翻譯中...",
|
||||
"input.upload.upload_from_local": "上傳本地文件..."
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折疊",
|
||||
@ -328,7 +330,7 @@
|
||||
"files": {
|
||||
"actions": "操作",
|
||||
"all": "所有檔案",
|
||||
"count": "數量",
|
||||
"count": "個檔案",
|
||||
"created_at": "建立時間",
|
||||
"delete": "刪除",
|
||||
"delete.content": "刪除檔案會刪除檔案在所有訊息中的引用,確定要刪除此檔案嗎?",
|
||||
@ -1062,7 +1064,8 @@
|
||||
"deleteServerConfirm": "確定要刪除此伺服器嗎?",
|
||||
"registry": "套件管理源",
|
||||
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題。",
|
||||
"registryDefault": "預設"
|
||||
"registryDefault": "預設",
|
||||
"not_support": "不支援此模型"
|
||||
},
|
||||
"messages.divider": "訊息間顯示分隔線",
|
||||
"messages.grid_columns": "訊息網格展示列數",
|
||||
@ -1261,7 +1264,28 @@
|
||||
},
|
||||
"title": "網路搜尋"
|
||||
},
|
||||
"general.auto_check_update.title": "啟用自動更新檢查"
|
||||
"general.auto_check_update.title": "啟用自動更新檢查",
|
||||
"quickPhrase": {
|
||||
"title": "快捷短語",
|
||||
"add": "新增短語",
|
||||
"edit": "編輯短語",
|
||||
"titleLabel": "標題",
|
||||
"contentLabel": "內容",
|
||||
"titlePlaceholder": "請輸入短語標題",
|
||||
"contentPlaceholder": "請輸入短語內容,支持使用變量,然後按Tab鍵可以快速定位到變量進行修改。比如:\n幫我規劃從${from}到${to}的行程,然後發送到${email}。",
|
||||
"delete": "刪除短語",
|
||||
"deleteConfirm": "刪除後無法復原,是否繼續?"
|
||||
},
|
||||
"quickPanel": {
|
||||
"title": "快捷選單",
|
||||
"close": "關閉",
|
||||
"select": "選擇",
|
||||
"page": "翻頁",
|
||||
"confirm": "確認",
|
||||
"back": "後退",
|
||||
"forward": "前進",
|
||||
"multiple": "多選"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意語言",
|
||||
@ -1286,7 +1310,10 @@
|
||||
"scroll_sync.disable": "關閉滾動同步",
|
||||
"scroll_sync.enable": "開啟滾動同步",
|
||||
"title": "翻譯",
|
||||
"tooltip.newline": "換行"
|
||||
"tooltip.newline": "換行",
|
||||
"menu": {
|
||||
"description": "對當前輸入框內容進行翻譯"
|
||||
}
|
||||
},
|
||||
"tray": {
|
||||
"quit": "結束",
|
||||
|
||||
@ -88,7 +88,7 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||
fileInfo={{
|
||||
name: item.file,
|
||||
ext: item.ext,
|
||||
extra: `${item.created_at} · ${t('files.count')} ${item.count} · ${item.size}`,
|
||||
extra: `${item.created_at} · ${item.count}${t('files.count')} · ${item.size}`,
|
||||
actions: item.actions
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShowTopics } from '@renderer/hooks/useStore'
|
||||
@ -31,7 +32,9 @@ const Chat: FC<Props> = (props) => {
|
||||
topic={props.activeTopic}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
/>
|
||||
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
|
||||
<QuickPanelProvider>
|
||||
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
|
||||
</QuickPanelProvider>
|
||||
</Main>
|
||||
{topicPosition === 'right' && showTopics && (
|
||||
<Tabs
|
||||
|
||||
@ -3,10 +3,15 @@ import { isVisionModel } from '@renderer/config/models'
|
||||
import { FileType, Model } from '@renderer/types'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { FC, useCallback, useImperativeHandle } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface AttachmentButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<AttachmentButtonRef | null>
|
||||
model: Model
|
||||
files: FileType[]
|
||||
setFiles: (files: FileType[]) => void
|
||||
@ -14,13 +19,13 @@ interface Props {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton, disabled }) => {
|
||||
const AttachmentButton: FC<Props> = ({ ref, model, files, setFiles, ToolbarButton, disabled }) => {
|
||||
const { t } = useTranslation()
|
||||
const extensions = isVisionModel(model)
|
||||
? [...imageExts, ...documentExts, ...textExts]
|
||||
: [...documentExts, ...textExts]
|
||||
|
||||
const onSelectFile = async () => {
|
||||
const onSelectFile = useCallback(async () => {
|
||||
const _files = await window.api.file.select({
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [
|
||||
@ -34,7 +39,15 @@ const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton, di
|
||||
if (_files) {
|
||||
setFiles([...files, ..._files])
|
||||
}
|
||||
}
|
||||
}, [files, setFiles])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
onSelectFile()
|
||||
}, [onSelectFile])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
|
||||
@ -25,7 +25,7 @@ const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
|
||||
uid: file.id,
|
||||
url: 'file://' + FileManager.getSafePath(file),
|
||||
status: 'done',
|
||||
name: file.name
|
||||
name: file.origin_name || file.name
|
||||
}) as UploadFile
|
||||
)}
|
||||
onRemove={(item) => setFiles(files.filter((file) => item.uid !== file.id))}
|
||||
|
||||
@ -1,18 +1,25 @@
|
||||
import {
|
||||
ClearOutlined,
|
||||
CodeOutlined,
|
||||
ColumnHeightOutlined,
|
||||
FileSearchOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
GlobalOutlined,
|
||||
HolderOutlined,
|
||||
PaperClipOutlined,
|
||||
PauseCircleOutlined,
|
||||
QuestionCircleOutlined
|
||||
QuestionCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
TranslationOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { isFunctionCallingModel, isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
@ -23,18 +30,20 @@ import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services
|
||||
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 { estimateMessageUsage, estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { sendMessage as _sendMessage } from '@renderer/store/messages'
|
||||
import { setSearching } from '@renderer/store/runtime'
|
||||
import { Assistant, FileType, KnowledgeBase, MCPServer, Message, Model, Topic } from '@renderer/types'
|
||||
import { classNames, delay, getFileExtension } from '@renderer/utils'
|
||||
import { Assistant, FileType, KnowledgeBase, KnowledgeItem, MCPServer, Message, Model, Topic } from '@renderer/types'
|
||||
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { Button, Popconfirm, Tooltip } from 'antd'
|
||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import dayjs from 'dayjs'
|
||||
import Logger from 'electron-log/renderer'
|
||||
import { debounce, isEmpty } from 'lodash'
|
||||
import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@ -43,16 +52,19 @@ import { useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import NarrowLayout from '../Messages/NarrowLayout'
|
||||
import AttachmentButton from './AttachmentButton'
|
||||
import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton'
|
||||
import AttachmentPreview from './AttachmentPreview'
|
||||
import GenerateImageButton from './GenerateImageButton'
|
||||
import KnowledgeBaseButton from './KnowledgeBaseButton'
|
||||
import MCPToolsButton from './MCPToolsButton'
|
||||
import MentionModelsButton from './MentionModelsButton'
|
||||
import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
|
||||
import KnowledgeBaseInput from './KnowledgeBaseInput'
|
||||
import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton'
|
||||
import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton'
|
||||
import MentionModelsInput from './MentionModelsInput'
|
||||
import NewContextButton from './NewContextButton'
|
||||
import QuickPhrasesButton, { QuickPhrasesButtonRef } from './QuickPhrasesButton'
|
||||
import SendMessageButton from './SendMessageButton'
|
||||
import TokenCount from './TokenCount'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
@ -93,7 +105,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
|
||||
const [mentionModels, setMentionModels] = useState<Model[]>([])
|
||||
const [enabledMCPs, setEnabledMCPs] = useState<MCPServer[]>(assistant.mcpServers || [])
|
||||
const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [textareaHeight, setTextareaHeight] = useState<number>()
|
||||
const startDragY = useRef<number>(0)
|
||||
@ -103,13 +114,20 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
||||
const navigate = useNavigate()
|
||||
const { activedMcpServers } = useMCPServers()
|
||||
const { bases: knowledgeBases } = useKnowledgeBases()
|
||||
|
||||
const quickPanel = useQuickPanel()
|
||||
|
||||
const showKnowledgeIcon = useSidebarIconShow('knowledge')
|
||||
const showMCPToolsIcon = isFunctionCallingModel(model)
|
||||
|
||||
const [tokenCount, setTokenCount] = useState(0)
|
||||
|
||||
const [mentionFromKeyboard, setMentionFromKeyboard] = useState(false)
|
||||
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null)
|
||||
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
|
||||
const knowledgeBaseButtonRef = useRef<KnowledgeBaseButtonRef>(null)
|
||||
const mcpToolsButtonRef = useRef<MCPToolsButtonRef>(null)
|
||||
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedEstimate = useCallback(
|
||||
@ -208,6 +226,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}, [
|
||||
assistant,
|
||||
dispatch,
|
||||
enabledMCPs,
|
||||
files,
|
||||
inputEmpty,
|
||||
loading,
|
||||
@ -217,11 +236,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
selectedKnowledgeBases,
|
||||
text,
|
||||
topic,
|
||||
enabledMCPs,
|
||||
activedMcpServers
|
||||
])
|
||||
|
||||
const translate = async () => {
|
||||
const translate = useCallback(async () => {
|
||||
if (isTranslating) {
|
||||
return
|
||||
}
|
||||
@ -236,28 +254,161 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
} finally {
|
||||
setIsTranslating(false)
|
||||
}
|
||||
}
|
||||
}, [isTranslating, text, targetLanguage, 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: <FileSearchOutlined />,
|
||||
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'),
|
||||
list: [
|
||||
{
|
||||
label: t('chat.input.upload.upload_from_local'),
|
||||
description: '',
|
||||
icon: <PaperClipOutlined />,
|
||||
action: () => {
|
||||
attachmentButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
...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: <FileSearchOutlined />,
|
||||
disabled: length === 0,
|
||||
isMenu: true,
|
||||
action: () => openKnowledgeFileList(base)
|
||||
}
|
||||
})
|
||||
],
|
||||
symbol: 'file'
|
||||
})
|
||||
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t])
|
||||
|
||||
const quickPanelMenu = useMemo<QuickPanelListItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
label: t('settings.quickPhrase.title'),
|
||||
description: '',
|
||||
icon: <ThunderboltOutlined />,
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
quickPhrasesButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('agents.edit.model.select.title'),
|
||||
description: '',
|
||||
icon: '@',
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
mentionModelsButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.input.knowledge_base'),
|
||||
description: '',
|
||||
icon: <FileSearchOutlined />,
|
||||
isMenu: true,
|
||||
disabled: !showKnowledgeIcon || files.length > 0,
|
||||
action: () => {
|
||||
knowledgeBaseButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('settings.mcp.title'),
|
||||
description: showMCPToolsIcon ? '' : t('settings.mcp.not_support'),
|
||||
icon: <CodeOutlined />,
|
||||
isMenu: true,
|
||||
disabled: !showMCPToolsIcon,
|
||||
action: () => {
|
||||
mcpToolsButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
|
||||
description: '',
|
||||
icon: <PaperClipOutlined />,
|
||||
isMenu: true,
|
||||
action: openSelectFileMenu
|
||||
},
|
||||
{
|
||||
label: t('translate.title'),
|
||||
description: t('translate.menu.description'),
|
||||
icon: <TranslationOutlined />,
|
||||
action: () => {
|
||||
if (!text) return
|
||||
translate()
|
||||
}
|
||||
}
|
||||
]
|
||||
}, [files.length, model, openSelectFileMenu, showKnowledgeIcon, showMCPToolsIcon, t, text, translate])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const isEnterPressed = event.keyCode == 13
|
||||
|
||||
if (event.key === '@') {
|
||||
// 按下Tab键,自动选中${xxx}
|
||||
if (event.key === 'Tab' && inputFocus) {
|
||||
event.preventDefault()
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const cursorPosition = textArea.selectionStart
|
||||
const textBeforeCursor = text.substring(0, cursorPosition)
|
||||
if (cursorPosition === 0 || textBeforeCursor.endsWith(' ')) {
|
||||
setMentionFromKeyboard(true)
|
||||
EventEmitter.emit(EVENT_NAMES.SHOW_MODEL_SELECTOR)
|
||||
setIsMentionPopupOpen(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!textArea) return
|
||||
|
||||
if (event.key === 'Escape' && isMentionPopupOpen) {
|
||||
setIsMentionPopupOpen(false)
|
||||
return
|
||||
const cursorPosition = textArea.selectionStart
|
||||
const selectionLength = textArea.selectionEnd - textArea.selectionStart
|
||||
const text = textArea.value
|
||||
|
||||
let match = text.slice(cursorPosition + selectionLength).match(/\$\{[^}]+\}/)
|
||||
let startIndex = -1
|
||||
|
||||
if (!match) {
|
||||
match = text.match(/\$\{[^}]+\}/)
|
||||
startIndex = match?.index ?? -1
|
||||
} else {
|
||||
startIndex = cursorPosition + selectionLength + match.index!
|
||||
}
|
||||
|
||||
if (startIndex !== -1) {
|
||||
const endIndex = startIndex + match![0].length
|
||||
textArea.setSelectionRange(startIndex, endIndex)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (autoTranslateWithSpace) {
|
||||
@ -289,33 +440,29 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
|
||||
if (isEnterPressed && !event.shiftKey && sendMessageShortcut === 'Enter') {
|
||||
if (isMentionPopupOpen) {
|
||||
return event.preventDefault()
|
||||
}
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) {
|
||||
if (isMentionPopupOpen) {
|
||||
return event.preventDefault()
|
||||
}
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
if (sendMessageShortcut === 'Ctrl+Enter' && isEnterPressed && event.ctrlKey) {
|
||||
if (isMentionPopupOpen) {
|
||||
return event.preventDefault()
|
||||
}
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
if (sendMessageShortcut === 'Command+Enter' && isEnterPressed && event.metaKey) {
|
||||
if (isMentionPopupOpen) {
|
||||
return event.preventDefault()
|
||||
}
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
@ -324,6 +471,15 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
setMentionModels((prev) => prev.slice(0, -1))
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && text.trim() === '' && selectedKnowledgeBases.length > 0) {
|
||||
setSelectedKnowledgeBases((prev) => {
|
||||
const newSelectedKnowledgeBases = prev.slice(0, -1)
|
||||
updateAssistant({ ...assistant, knowledge_bases: newSelectedKnowledgeBases })
|
||||
return newSelectedKnowledgeBases
|
||||
})
|
||||
return event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const addNewTopic = useCallback(async () => {
|
||||
@ -388,16 +544,20 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const newText = e.target.value
|
||||
setText(newText)
|
||||
|
||||
// Check if @ was deleted
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const cursorPosition = textArea.selectionStart
|
||||
const textBeforeCursor = newText.substring(0, cursorPosition)
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||
const cursorPosition = textArea?.selectionStart ?? 0
|
||||
const lastSymbol = newText[cursorPosition - 1]
|
||||
|
||||
if (lastAtIndex === -1 || textBeforeCursor.slice(lastAtIndex + 1).includes(' ')) {
|
||||
setIsMentionPopupOpen(false)
|
||||
}
|
||||
if (!quickPanel.isVisible && lastSymbol === '/') {
|
||||
quickPanel.open({
|
||||
title: t('settings.quickPanel.title'),
|
||||
list: quickPanelMenu,
|
||||
symbol: '/'
|
||||
})
|
||||
}
|
||||
|
||||
if (!quickPanel.isVisible && lastSymbol === '@') {
|
||||
mentionModelsButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
}
|
||||
|
||||
@ -603,33 +763,14 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
setSelectedKnowledgeBases(bases ?? [])
|
||||
}
|
||||
|
||||
const onMentionModel = (model: Model, fromKeyboard: boolean = false) => {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
if (fromKeyboard) {
|
||||
const cursorPosition = textArea.selectionStart
|
||||
const textBeforeCursor = text.substring(0, cursorPosition)
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||
|
||||
if (lastAtIndex !== -1) {
|
||||
const newText = text.substring(0, lastAtIndex) + text.substring(cursorPosition)
|
||||
setText(newText)
|
||||
}
|
||||
}
|
||||
|
||||
setMentionModels((prev) => [...prev, model])
|
||||
setIsMentionPopupOpen(false)
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, 0)
|
||||
setMentionFromKeyboard(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveModel = (model: Model) => {
|
||||
setMentionModels(mentionModels.filter((m) => m.id !== model.id))
|
||||
}
|
||||
|
||||
const handleRemoveKnowledgeBase = (knowledgeBase: KnowledgeBase) => {
|
||||
setSelectedKnowledgeBases(selectedKnowledgeBases.filter((kb) => kb.id !== knowledgeBase.id))
|
||||
}
|
||||
|
||||
const toggelEnableMCP = (mcp: MCPServer) => {
|
||||
setEnabledMCPs((prev) => {
|
||||
const exists = prev.some((item) => item.id === mcp.id)
|
||||
@ -687,15 +828,27 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
})
|
||||
}
|
||||
const onMentionModel = (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]
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container onDragOver={handleDragOver} onDrop={handleDrop} className="inputbar">
|
||||
<NarrowLayout style={{ width: '100%' }}>
|
||||
<QuickPanelView setInputText={setText} />
|
||||
<InputBarContainer
|
||||
id="inputbar"
|
||||
className={classNames('inputbar-container', inputFocus && 'focus')}
|
||||
ref={containerRef}>
|
||||
<AttachmentPreview files={files} setFiles={setFiles} />
|
||||
<KnowledgeBaseInput
|
||||
selectedKnowledgeBases={selectedKnowledgeBases}
|
||||
onRemoveKnowledgeBase={handleRemoveKnowledgeBase}
|
||||
/>
|
||||
<MentionModelsInput selectedModels={mentionModels} onRemoveModel={handleRemoveModel} />
|
||||
<Textarea
|
||||
value={text}
|
||||
@ -710,7 +863,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
ref={textareaRef}
|
||||
style={{
|
||||
fontSize,
|
||||
height: textareaHeight ? `${textareaHeight}px` : undefined
|
||||
minHeight: textareaHeight ? `${textareaHeight}px` : undefined
|
||||
}}
|
||||
styles={{ textarea: TextareaStyle }}
|
||||
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
@ -737,7 +890,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
<FormOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} />
|
||||
<AttachmentButton
|
||||
ref={attachmentButtonRef}
|
||||
model={model}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
ToolbarButton={ToolbarButton}
|
||||
/>
|
||||
<Tooltip placement="top" title={t('chat.input.web_search')} arrow>
|
||||
<ToolbarButton type="text" onClick={onEnableWebSearch}>
|
||||
<GlobalOutlined
|
||||
@ -747,6 +906,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
</Tooltip>
|
||||
{showKnowledgeIcon && (
|
||||
<KnowledgeBaseButton
|
||||
ref={knowledgeBaseButtonRef}
|
||||
selectedBases={selectedKnowledgeBases}
|
||||
onSelect={handleKnowledgeBaseSelect}
|
||||
ToolbarButton={ToolbarButton}
|
||||
@ -755,6 +915,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
)}
|
||||
{showMCPToolsIcon && (
|
||||
<MCPToolsButton
|
||||
ref={mcpToolsButtonRef}
|
||||
enabledMCPs={enabledMCPs}
|
||||
toggelEnableMCP={toggelEnableMCP}
|
||||
ToolbarButton={ToolbarButton}
|
||||
@ -767,8 +928,15 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
ToolbarButton={ToolbarButton}
|
||||
/>
|
||||
<MentionModelsButton
|
||||
ref={mentionModelsButtonRef}
|
||||
mentionModels={mentionModels}
|
||||
onMentionModel={(model) => onMentionModel(model, mentionFromKeyboard)}
|
||||
onMentionModel={onMentionModel}
|
||||
ToolbarButton={ToolbarButton}
|
||||
/>
|
||||
<QuickPhrasesButton
|
||||
ref={quickPhrasesButtonRef}
|
||||
setInputValue={setText}
|
||||
resizeTextArea={resizeTextArea}
|
||||
ToolbarButton={ToolbarButton}
|
||||
/>
|
||||
<Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} arrow>
|
||||
@ -852,6 +1020,7 @@ const DragHandle = styled.div`
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const InputBarContainer = styled.div`
|
||||
@ -859,7 +1028,7 @@ const InputBarContainer = styled.div`
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
margin: 14px 20px;
|
||||
margin-top: 12px;
|
||||
margin-top: 0;
|
||||
border-radius: 15px;
|
||||
padding-top: 6px; // 为拖动手柄留出空间
|
||||
background-color: var(--color-background-opacity);
|
||||
|
||||
@ -1,81 +1,97 @@
|
||||
import { CheckOutlined, FileSearchOutlined } from '@ant-design/icons'
|
||||
import { FileSearchOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { Popover, Select, SelectProps, Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { Tooltip } from 'antd'
|
||||
import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
export interface KnowledgeBaseButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<KnowledgeBaseButtonRef | null>
|
||||
selectedBases?: KnowledgeBase[]
|
||||
onSelect: (bases: KnowledgeBase[]) => void
|
||||
disabled?: boolean
|
||||
ToolbarButton?: any
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
const KnowledgeBaseSelector: FC<Props> = ({ selectedBases, onSelect }) => {
|
||||
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled, ToolbarButton }) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const quickPanel = useQuickPanel()
|
||||
const knowledgeState = useAppSelector((state) => state.knowledge)
|
||||
const knowledgeOptions: SelectProps['options'] = knowledgeState.bases.map((base) => ({
|
||||
label: base.name,
|
||||
value: base.id
|
||||
const selectedBasesRef = useRef(selectedBases)
|
||||
|
||||
useEffect(() => {
|
||||
selectedBasesRef.current = selectedBases
|
||||
}, [selectedBases])
|
||||
|
||||
const handleBaseSelect = useCallback(
|
||||
(base: KnowledgeBase) => {
|
||||
const currentSelectedBases = selectedBasesRef.current
|
||||
|
||||
if (currentSelectedBases?.some((selected) => selected.id === base.id)) {
|
||||
onSelect(currentSelectedBases.filter((selected) => selected.id !== base.id))
|
||||
} else {
|
||||
onSelect([...(currentSelectedBases || []), base])
|
||||
}
|
||||
},
|
||||
[onSelect]
|
||||
)
|
||||
|
||||
const baseItems = useMemo<QuickPanelListItem[]>(() => {
|
||||
const newList: QuickPanelListItem[] = knowledgeState.bases.map((base) => ({
|
||||
label: base.name,
|
||||
description: `${base.items.length} ${t('files.count')}`,
|
||||
icon: <FileSearchOutlined />,
|
||||
action: () => handleBaseSelect(base),
|
||||
isSelected: selectedBases?.some((selected) => selected.id === base.id)
|
||||
}))
|
||||
newList.push({
|
||||
label: t('knowledge.add.title') + '...',
|
||||
icon: <PlusOutlined />,
|
||||
action: () => navigate('/knowledge'),
|
||||
isSelected: false
|
||||
})
|
||||
return newList
|
||||
}, [knowledgeState.bases, handleBaseSelect, selectedBases, t, navigate])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanel.open({
|
||||
title: t('chat.input.knowledge_base'),
|
||||
list: baseItems,
|
||||
symbol: '#',
|
||||
multiple: true,
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
}
|
||||
})
|
||||
}, [baseItems, quickPanel, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '#') {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
}
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
return (
|
||||
<SelectorContainer>
|
||||
{knowledgeState.bases.length === 0 ? (
|
||||
<EmptyMessage>{t('knowledge.no_bases')}</EmptyMessage>
|
||||
) : (
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={selectedBases?.map((base) => base.id)}
|
||||
allowClear
|
||||
placeholder={t('agents.add.knowledge_base.placeholder')}
|
||||
menuItemSelectedIcon={<CheckOutlined />}
|
||||
options={knowledgeOptions}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
onChange={(ids) => {
|
||||
const newSelected = knowledgeState.bases.filter((base) => ids.includes(base.id))
|
||||
onSelect(newSelected)
|
||||
}}
|
||||
style={{ width: '200px' }}
|
||||
/>
|
||||
)}
|
||||
</SelectorContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const KnowledgeBaseButton: FC<Props> = ({ selectedBases, onSelect, disabled, ToolbarButton }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.knowledge_base')} arrow>
|
||||
<Popover
|
||||
placement="top"
|
||||
content={<KnowledgeBaseSelector selectedBases={selectedBases} onSelect={onSelect} />}
|
||||
overlayStyle={{ maxWidth: 400 }}
|
||||
trigger="click">
|
||||
<ToolbarButton type="text" disabled={disabled}>
|
||||
<FileSearchOutlined
|
||||
style={{ color: selectedBases && selectedBases?.length > 0 ? 'var(--color-link)' : 'var(--color-icon)' }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</Popover>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel} disabled={disabled}>
|
||||
<FileSearchOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectorContainer = styled.div`
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const EmptyMessage = styled.div`
|
||||
padding: 8px;
|
||||
`
|
||||
|
||||
export default KnowledgeBaseButton
|
||||
|
||||
42
src/renderer/src/pages/home/Inputbar/KnowledgeBaseInput.tsx
Normal file
42
src/renderer/src/pages/home/Inputbar/KnowledgeBaseInput.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { FileSearchOutlined } from '@ant-design/icons'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { ConfigProvider, Flex, Tag } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const KnowledgeBaseInput: FC<{
|
||||
selectedKnowledgeBases: KnowledgeBase[]
|
||||
onRemoveKnowledgeBase: (knowledgeBase: KnowledgeBase) => void
|
||||
}> = ({ selectedKnowledgeBases, onRemoveKnowledgeBase }) => {
|
||||
return (
|
||||
<Container gap="4px 0" wrap>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Tag: {
|
||||
borderRadiusSM: 100
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{selectedKnowledgeBases.map((knowledgeBase) => (
|
||||
<Tag
|
||||
icon={<FileSearchOutlined />}
|
||||
bordered={false}
|
||||
color="success"
|
||||
key={knowledgeBase.id}
|
||||
closable
|
||||
onClose={() => onRemoveKnowledgeBase(knowledgeBase)}>
|
||||
{knowledgeBase.name}
|
||||
</Tag>
|
||||
))}
|
||||
</ConfigProvider>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
width: 100%;
|
||||
padding: 5px 15px 0;
|
||||
`
|
||||
|
||||
export default KnowledgeBaseInput
|
||||
@ -1,195 +1,93 @@
|
||||
import { CodeOutlined } from '@ant-design/icons'
|
||||
import { CodeOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { initializeMCPServers } from '@renderer/store/mcp'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Dropdown, Switch, Tooltip } from 'antd'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { Tooltip } from 'antd'
|
||||
import { FC, useCallback, useEffect, useImperativeHandle, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
export interface MCPToolsButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<MCPToolsButtonRef | null>
|
||||
enabledMCPs: MCPServer[]
|
||||
toggelEnableMCP: (server: MCPServer) => void
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton }) => {
|
||||
const MCPToolsButton: FC<Props> = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarButton }) => {
|
||||
const { activedMcpServers, mcpServers } = useMCPServers()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<any>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const availableMCPs = activedMcpServers.filter((server) => enabledMCPs.some((s) => s.id === server.id))
|
||||
|
||||
const buttonEnabled = availableMCPs.length > 0
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const newList: QuickPanelListItem[] = activedMcpServers.map((server) => ({
|
||||
label: server.name,
|
||||
description: server.description || server.baseUrl,
|
||||
icon: <CodeOutlined />,
|
||||
action: () => toggelEnableMCP(server),
|
||||
isSelected: enabledMCPs.some((s) => s.id === server.id)
|
||||
}))
|
||||
|
||||
newList.push({
|
||||
label: t('settings.mcp.addServer') + '...',
|
||||
icon: <PlusOutlined />,
|
||||
action: () => navigate('/settings/mcp')
|
||||
})
|
||||
return newList
|
||||
}, [activedMcpServers, t, enabledMCPs, toggelEnableMCP, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
initializeMCPServers(mcpServers, dispatch)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const truncateText = (text: string, maxLength: number = 50) => {
|
||||
if (!text || text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
// Check if all active servers are enabled
|
||||
|
||||
const anyEnable = activedMcpServers.some((server) =>
|
||||
enabledMCPs.some((enabledServer) => enabledServer.id === server.id)
|
||||
)
|
||||
|
||||
const enableAll = () => activedMcpServers.forEach(toggelEnableMCP)
|
||||
|
||||
const disableAll = () =>
|
||||
activedMcpServers.forEach((s) => {
|
||||
enabledMCPs.forEach((enabledServer) => {
|
||||
if (enabledServer.id === s.id) {
|
||||
toggelEnableMCP(s)
|
||||
}
|
||||
})
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanel.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: menuItems,
|
||||
symbol: 'mcp',
|
||||
multiple: true,
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
}
|
||||
})
|
||||
}, [menuItems, quickPanel, t])
|
||||
|
||||
const toggelAll = () => {
|
||||
if (anyEnable) {
|
||||
disableAll()
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === 'mcp') {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
enableAll()
|
||||
openQuickPanel()
|
||||
}
|
||||
}
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
const menu = (
|
||||
<div ref={menuRef} className="ant-dropdown-menu">
|
||||
<DropdownHeader className="dropdown-header">
|
||||
<div className="header-content">
|
||||
<h4>{t('settings.mcp.title')}</h4>
|
||||
<div className="enable-all-container">
|
||||
{/* <span className="enable-all-label">{t('mcp.enable_all')}</span> */}
|
||||
<Switch size="small" checked={anyEnable} onChange={toggelAll} />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownHeader>
|
||||
<DropdownBody>
|
||||
{activedMcpServers.length > 0 ? (
|
||||
activedMcpServers.map((server) => (
|
||||
<McpServerItems key={server.id} className="ant-dropdown-menu-item">
|
||||
<div className="server-info">
|
||||
<div className="server-name">{server.name}</div>
|
||||
{server.description && (
|
||||
<Tooltip title={server.description} placement="bottom">
|
||||
<div className="server-description">{truncateText(server.description)}</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{server.baseUrl && <div className="server-url">{server.baseUrl}</div>}
|
||||
</div>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={enabledMCPs.some((s) => s.id === server.id)}
|
||||
onChange={() => toggelEnableMCP(server)}
|
||||
/>
|
||||
</McpServerItems>
|
||||
))
|
||||
) : (
|
||||
<div className="ant-dropdown-menu-item-group">
|
||||
<div className="ant-dropdown-menu-item no-results">{t('settings.mcp.noServers')}</div>
|
||||
</div>
|
||||
)}
|
||||
</DropdownBody>
|
||||
</div>
|
||||
)
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
if (activedMcpServers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownRender={() => menu}
|
||||
trigger={['click']}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
overlayClassName="mention-models-dropdown">
|
||||
<Tooltip placement="top" title={t('settings.mcp.title')} arrow>
|
||||
<ToolbarButton type="text" ref={dropdownRef}>
|
||||
<CodeOutlined style={{ color: buttonEnabled ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<Tooltip placement="top" title={t('settings.mcp.title')} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<CodeOutlined style={{ color: buttonEnabled ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const McpServerItems = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
|
||||
.server-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.server-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
max-width: 400px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-description {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.server-url {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-4);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DropdownHeader = styled.div`
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 4px;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
color: var(--color-text-1);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.enable-all-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.enable-all-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DropdownBody = styled.div`
|
||||
padding-bottom: 10px;
|
||||
`
|
||||
|
||||
export default MCPToolsButton
|
||||
|
||||
@ -1,179 +1,93 @@
|
||||
import { PushpinOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import ModelTags from '@renderer/components/ModelTags'
|
||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelListItem } from '@renderer/components/QuickPanel/types'
|
||||
import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { Avatar, Dropdown, Tooltip } from 'antd'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Avatar, Tooltip } from 'antd'
|
||||
import { first, sortBy } from 'lodash'
|
||||
import { FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
export interface MentionModelsButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<MentionModelsButtonRef | null>
|
||||
mentionModels: Model[]
|
||||
onMentionModel: (model: Model, fromKeyboard?: boolean) => void
|
||||
onMentionModel: (model: Model) => void
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
const MentionModelsButton: FC<Props> = ({ mentionModels, onMentionModel: onSelect, ToolbarButton }) => {
|
||||
const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, ToolbarButton }) => {
|
||||
const { providers } = useProviders()
|
||||
const [pinnedModels, setPinnedModels] = useState<string[]>([])
|
||||
const { t } = useTranslation()
|
||||
const dropdownRef = useRef<any>(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const itemRefs = useRef<Array<HTMLDivElement | null>>([])
|
||||
// Add a new state to track if menu was dismissed
|
||||
const [menuDismissed, setMenuDismissed] = useState(false)
|
||||
// Add a state to track if the model selector was triggered by keyboard
|
||||
const [fromKeyboard, setFromKeyboard] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const quickPanel = useQuickPanel()
|
||||
|
||||
const setItemRef = (index: number, el: HTMLDivElement | null) => {
|
||||
itemRefs.current[index] = el
|
||||
}
|
||||
|
||||
const togglePin = useCallback(
|
||||
async (modelId: string) => {
|
||||
const newPinnedModels = pinnedModels.includes(modelId)
|
||||
? pinnedModels.filter((id) => id !== modelId)
|
||||
: [...pinnedModels, modelId]
|
||||
|
||||
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
|
||||
setPinnedModels(newPinnedModels)
|
||||
},
|
||||
[pinnedModels]
|
||||
)
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(model: Model) => {
|
||||
// Check if model is already selected
|
||||
if (mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(model))) {
|
||||
return
|
||||
}
|
||||
onSelect(model, fromKeyboard)
|
||||
setIsOpen(false)
|
||||
},
|
||||
[fromKeyboard, mentionModels, onSelect]
|
||||
)
|
||||
|
||||
const modelMenuItems = useMemo(() => {
|
||||
const items = providers
|
||||
const modelItems = useMemo(() => {
|
||||
// Get all models from providers
|
||||
const allModels = providers
|
||||
.filter((p) => p.models && p.models.length > 0)
|
||||
.map((p) => {
|
||||
const filteredModels = sortBy(p.models, ['group', 'name'])
|
||||
.flatMap((p) =>
|
||||
p.models
|
||||
.filter((m) => !isEmbeddingModel(m))
|
||||
.filter((m) => !isRerankModel(m))
|
||||
// Filter out pinned models from regular groups
|
||||
.filter((m) => !pinnedModels.includes(getModelUniqId(m)))
|
||||
// Filter by search text
|
||||
.filter((m) => {
|
||||
if (!searchText) return true
|
||||
return (
|
||||
m.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
m.id.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
})
|
||||
.map((m) => ({
|
||||
key: getModelUniqId(m),
|
||||
model: m,
|
||||
label: (
|
||||
<ModelItem>
|
||||
<ModelNameRow>
|
||||
<span>{m?.name}</span> <ModelTags model={m} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin(getModelUniqId(m))
|
||||
}}
|
||||
$isPinned={pinnedModels.includes(getModelUniqId(m))}>
|
||||
<PushpinOutlined />
|
||||
</PinIcon>
|
||||
</ModelItem>
|
||||
),
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m.id)} size={24}>
|
||||
{first(m.name)}
|
||||
</Avatar>
|
||||
),
|
||||
onClick: () => handleModelSelect(m)
|
||||
provider: p,
|
||||
isPinned: pinnedModels.includes(getModelUniqId(m))
|
||||
}))
|
||||
)
|
||||
|
||||
return filteredModels.length > 0
|
||||
? {
|
||||
key: p.id,
|
||||
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||
type: 'group' as const,
|
||||
children: filteredModels
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter((group): group is NonNullable<typeof group> => group !== null)
|
||||
// Sort by pinned status and name
|
||||
const newList: QuickPanelListItem[] = sortBy(allModels, ['isPinned', 'model.name'])
|
||||
.reverse()
|
||||
.map((item) => ({
|
||||
label: `${item.provider.isSystem ? t(`provider.${item.provider.id}`) : item.provider.name} | ${item.model.name}`,
|
||||
description: <ModelTags model={item.model} />,
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(item.model.id)} size={20}>
|
||||
{first(item.model.name)}
|
||||
</Avatar>
|
||||
),
|
||||
action: () => onMentionModel(item.model),
|
||||
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(item.model))
|
||||
}))
|
||||
newList.push({
|
||||
label: t('settings.models.add.add_model') + '...',
|
||||
icon: <PlusOutlined />,
|
||||
action: () => navigate('/settings/provider'),
|
||||
isSelected: false
|
||||
})
|
||||
return newList
|
||||
}, [providers, t, pinnedModels, mentionModels, onMentionModel, navigate])
|
||||
|
||||
if (pinnedModels.length > 0) {
|
||||
const pinnedItems = providers
|
||||
.filter((p): p is Provider => p.models && p.models.length > 0)
|
||||
.flatMap((p) =>
|
||||
p.models
|
||||
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
|
||||
.map((m) => ({
|
||||
key: getModelUniqId(m),
|
||||
model: m,
|
||||
provider: p
|
||||
}))
|
||||
)
|
||||
.map((m) => ({
|
||||
...m,
|
||||
key: m.key + 'pinned',
|
||||
label: (
|
||||
<ModelItem>
|
||||
<ModelNameRow>
|
||||
<span>
|
||||
{m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name}
|
||||
</span>{' '}
|
||||
<ModelTags model={m.model} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin(getModelUniqId(m.model))
|
||||
}}
|
||||
$isPinned={true}>
|
||||
<PushpinOutlined />
|
||||
</PinIcon>
|
||||
</ModelItem>
|
||||
),
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m.model.id)} size={24}>
|
||||
{first(m.model.name)}
|
||||
</Avatar>
|
||||
),
|
||||
onClick: () => handleModelSelect(m.model)
|
||||
}))
|
||||
|
||||
if (pinnedItems.length > 0) {
|
||||
items.unshift({
|
||||
key: 'pinned',
|
||||
label: t('models.pinned'),
|
||||
type: 'group' as const,
|
||||
children: pinnedItems
|
||||
})
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanel.open({
|
||||
title: t('agents.edit.model.select.title'),
|
||||
list: modelItems,
|
||||
symbol: '@',
|
||||
multiple: true,
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
}
|
||||
})
|
||||
}, [modelItems, quickPanel, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '@') {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
}
|
||||
|
||||
// Remove empty groups
|
||||
return items.filter((group) => group.children.length > 0)
|
||||
}, [providers, pinnedModels, t, searchText, togglePin, handleModelSelect])
|
||||
|
||||
// Get flattened list of all model items
|
||||
const flatModelItems = useMemo(() => {
|
||||
return modelMenuItems.flatMap((group) => group?.children || [])
|
||||
}, [modelMenuItems])
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
useEffect(() => {
|
||||
const loadPinnedModels = async () => {
|
||||
@ -183,228 +97,17 @@ const MentionModelsButton: FC<Props> = ({ mentionModels, onMentionModel: onSelec
|
||||
loadPinnedModels()
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isOpen && selectedIndex > -1 && itemRefs.current[selectedIndex]) {
|
||||
requestAnimationFrame(() => {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
}, [isOpen, selectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
const showModelSelector = () => {
|
||||
dropdownRef.current?.click()
|
||||
itemRefs.current = []
|
||||
setIsOpen(true)
|
||||
setSelectedIndex(0)
|
||||
setSearchText('')
|
||||
setMenuDismissed(false) // Reset dismissed flag when manually showing selector
|
||||
setFromKeyboard(true) // Set fromKeyboard to true when triggered by keyboard
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev < flatModelItems.length - 1 ? prev + 1 : 0
|
||||
itemRefs.current[newIndex]?.scrollIntoView({ block: 'nearest' })
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev > 0 ? prev - 1 : flatModelItems.length - 1
|
||||
itemRefs.current[newIndex]?.scrollIntoView({ block: 'nearest' })
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < flatModelItems.length) {
|
||||
const selectedModel = flatModelItems[selectedIndex].model
|
||||
if (!mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(selectedModel))) {
|
||||
flatModelItems[selectedIndex].onClick()
|
||||
}
|
||||
setIsOpen(false)
|
||||
setSearchText('')
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
setSearchText('')
|
||||
setMenuDismissed(true) // Set dismissed flag when Escape is pressed
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextChange = (e: Event) => {
|
||||
const textArea = e.target as HTMLTextAreaElement
|
||||
const cursorPosition = textArea.selectionStart
|
||||
const textBeforeCursor = textArea.value.substring(0, cursorPosition)
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||
const textBeforeLastAt = textBeforeCursor.slice(0, lastAtIndex)
|
||||
if (lastAtIndex === -1 || textBeforeCursor.slice(lastAtIndex + 1).includes(' ')) {
|
||||
setIsOpen(false)
|
||||
setSearchText('')
|
||||
setMenuDismissed(false) // Reset dismissed flag when @ is removed
|
||||
} else {
|
||||
// Only open menu if it wasn't explicitly dismissed
|
||||
if (!menuDismissed && (textBeforeLastAt.slice(-1) === ' ' || lastAtIndex === 0)) {
|
||||
setIsOpen(true)
|
||||
const searchStr = textBeforeCursor.slice(lastAtIndex + 1)
|
||||
setSearchText(searchStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
if (textArea) {
|
||||
textArea.addEventListener('input', handleTextChange)
|
||||
}
|
||||
|
||||
EventEmitter.on(EVENT_NAMES.SHOW_MODEL_SELECTOR, showModelSelector)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
EventEmitter.off(EVENT_NAMES.SHOW_MODEL_SELECTOR, showModelSelector)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
if (textArea) {
|
||||
textArea.removeEventListener('input', handleTextChange)
|
||||
}
|
||||
}
|
||||
}, [isOpen, selectedIndex, flatModelItems, mentionModels, menuDismissed])
|
||||
|
||||
useEffect(() => {
|
||||
const updateScrollbarClass = () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (menuRef.current) {
|
||||
const hasScrollbar = menuRef.current.scrollHeight > menuRef.current.clientHeight
|
||||
menuRef.current.classList.toggle('has-scrollbar', hasScrollbar)
|
||||
menuRef.current.classList.toggle('no-scrollbar', !hasScrollbar)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Update on initial render and whenever content changes
|
||||
const observer = new MutationObserver(updateScrollbarClass)
|
||||
const resizeObserver = new ResizeObserver(updateScrollbarClass)
|
||||
|
||||
if (menuRef.current) {
|
||||
// Observe content changes
|
||||
observer.observe(menuRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true
|
||||
})
|
||||
|
||||
// Observe size changes
|
||||
resizeObserver.observe(menuRef.current)
|
||||
|
||||
// Initial check after a short delay to ensure DOM is ready
|
||||
setTimeout(updateScrollbarClass, 0)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [isOpen, searchText, flatModelItems.length]) // Add dependencies that affect content
|
||||
|
||||
const menu = (
|
||||
<div ref={menuRef} className="ant-dropdown-menu">
|
||||
{flatModelItems.length > 0 ? (
|
||||
modelMenuItems.map((group, groupIndex) => {
|
||||
if (!group) return null
|
||||
|
||||
// Calculate starting index for items in this group
|
||||
const startIndex = modelMenuItems.slice(0, groupIndex).reduce((acc, g) => acc + (g?.children?.length || 0), 0)
|
||||
|
||||
return (
|
||||
<div key={group.key} className="ant-dropdown-menu-item-group">
|
||||
<div className="ant-dropdown-menu-item-group-title">{group.label}</div>
|
||||
<div>
|
||||
{group.children.map((item, idx) => (
|
||||
<div
|
||||
key={item.key}
|
||||
ref={(el) => setItemRef(startIndex + idx, el)}
|
||||
className={`ant-dropdown-menu-item ${
|
||||
selectedIndex === startIndex + idx ? 'ant-dropdown-menu-item-selected' : ''
|
||||
}`}
|
||||
onClick={item.onClick}>
|
||||
<span className="ant-dropdown-menu-item-icon">{item.icon}</span>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="ant-dropdown-menu-item-group">
|
||||
<div className="ant-dropdown-menu-item no-results">{t('models.no_matches')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayStyle={{ marginBottom: 20 }}
|
||||
dropdownRender={() => menu}
|
||||
trigger={['click']}
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open)
|
||||
open && setFromKeyboard(false) // Set fromKeyboard to false when opened by button click
|
||||
}}
|
||||
overlayClassName="mention-models-dropdown">
|
||||
<Tooltip placement="top" title={t('agents.edit.model.select.title')} arrow>
|
||||
<ToolbarButton type="text" ref={dropdownRef}>
|
||||
<i className="iconfont icon-at" style={{ fontSize: 18 }}></i>
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<Tooltip placement="top" title={t('agents.edit.model.select.title')} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<i className="iconfont icon-at" style={{ fontSize: 18 }}></i>
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const ModelItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
gap: 16px;
|
||||
|
||||
&:hover {
|
||||
.pin-icon {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ModelNameRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const PinIcon = styled.span.attrs({ className: 'pin-icon' })<{ $isPinned: boolean }>`
|
||||
margin-left: auto;
|
||||
padding: 0 8px;
|
||||
opacity: ${(props) => (props.$isPinned ? 0.9 : 0)};
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
right: 0;
|
||||
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'inherit')};
|
||||
transform: ${(props) => (props.$isPinned ? 'rotate(-45deg)' : 'none')};
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
opacity: ${(props) => (props.$isPinned ? 1 : 0.7)} !important;
|
||||
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'inherit')};
|
||||
}
|
||||
`
|
||||
|
||||
export default MentionModelsButton
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Flex, Tag } from 'antd'
|
||||
import { ConfigProvider, Flex, Tag } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@ -20,23 +20,37 @@ const MentionModelsInput: FC<{
|
||||
|
||||
return (
|
||||
<Container gap="4px 0" wrap>
|
||||
{selectedModels.map((model) => (
|
||||
<Tag
|
||||
bordered={false}
|
||||
color="processing"
|
||||
key={getModelUniqId(model)}
|
||||
closable
|
||||
onClose={() => onRemoveModel(model)}>
|
||||
@{model.name} ({getProviderName(model)})
|
||||
</Tag>
|
||||
))}
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Tag: {
|
||||
borderRadiusSM: 100
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{selectedModels.map((model) => (
|
||||
<Tag
|
||||
icon={<i className="iconfont icon-at" />}
|
||||
bordered={false}
|
||||
color="processing"
|
||||
key={getModelUniqId(model)}
|
||||
closable
|
||||
onClose={() => onRemoveModel(model)}>
|
||||
{model.name} ({getProviderName(model)})
|
||||
</Tag>
|
||||
))}
|
||||
</ConfigProvider>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
width: 100%;
|
||||
padding: 10px 15px 0;
|
||||
padding: 5px 15px 0;
|
||||
i.iconfont {
|
||||
font-size: 12px;
|
||||
margin-inline-end: 7px;
|
||||
}
|
||||
`
|
||||
|
||||
export default MentionModelsInput
|
||||
|
||||
108
src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx
Normal file
108
src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelListItem, QuickPanelOpenOptions } from '@renderer/components/QuickPanel/types'
|
||||
import QuickPhraseService from '@renderer/services/QuickPhraseService'
|
||||
import { QuickPhrase } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
export interface QuickPhrasesButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<QuickPhrasesButtonRef | null>
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton }: Props) => {
|
||||
const [quickPhrasesList, setQuickPhrasesList] = useState<QuickPhrase[]>([])
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const loadQuickListPhrases = async () => {
|
||||
const phrases = await QuickPhraseService.getAll()
|
||||
setQuickPhrasesList(phrases.reverse())
|
||||
}
|
||||
loadQuickListPhrases()
|
||||
}, [])
|
||||
|
||||
const handlePhraseSelect = useCallback(
|
||||
(phrase: QuickPhrase) => {
|
||||
setTimeout(() => {
|
||||
setInputValue((prev) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
const cursorPosition = textArea.selectionStart
|
||||
const selectionStart = cursorPosition
|
||||
const selectionEndPosition = cursorPosition + phrase.content.length
|
||||
const newText = prev.slice(0, cursorPosition) + phrase.content + prev.slice(cursorPosition)
|
||||
|
||||
setTimeout(() => {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(selectionStart, selectionEndPosition)
|
||||
resizeTextArea()
|
||||
}, 10)
|
||||
return newText
|
||||
})
|
||||
}, 10)
|
||||
},
|
||||
[setInputValue, resizeTextArea]
|
||||
)
|
||||
|
||||
const phraseItems = useMemo(() => {
|
||||
const newList: QuickPanelListItem[] = quickPhrasesList.map((phrase) => ({
|
||||
label: phrase.title,
|
||||
description: phrase.content,
|
||||
icon: <ThunderboltOutlined />,
|
||||
action: () => handlePhraseSelect(phrase)
|
||||
}))
|
||||
newList.push({
|
||||
label: t('settings.quickPhrase.add') + '...',
|
||||
icon: <PlusOutlined />,
|
||||
action: () => navigate('/settings/quickPhrase')
|
||||
})
|
||||
return newList
|
||||
}, [quickPhrasesList, t, handlePhraseSelect, navigate])
|
||||
|
||||
const quickPanelOpenOptions = useMemo<QuickPanelOpenOptions>(
|
||||
() => ({
|
||||
title: t('settings.quickPhrase.title'),
|
||||
list: phraseItems,
|
||||
symbol: 'quick-phrases'
|
||||
}),
|
||||
[phraseItems, t]
|
||||
)
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanel.open(quickPanelOpenOptions)
|
||||
}, [quickPanel, quickPanelOpenOptions])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === 'quick-phrases') {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
}
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('settings.quickPhrase.title')} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<ThunderboltOutlined />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(QuickPhrasesButton)
|
||||
@ -20,6 +20,7 @@ const Container = styled.div`
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
export default NarrowLayout
|
||||
|
||||
161
src/renderer/src/pages/settings/QuickPhraseSettings/index.tsx
Normal file
161
src/renderer/src/pages/settings/QuickPhraseSettings/index.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import QuickPhraseService from '@renderer/services/QuickPhraseService'
|
||||
import { QuickPhrase } from '@renderer/types'
|
||||
import { Button, Flex, Input, Modal, Popconfirm, Space } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitle } from '..'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
const QuickPhraseSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [phrasesList, setPhrasesList] = useState<QuickPhrase[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingPhrase, setEditingPhrase] = useState<QuickPhrase | null>(null)
|
||||
const [formData, setFormData] = useState({ title: '', content: '' })
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const loadPhrases = async () => {
|
||||
const data = await QuickPhraseService.getAll()
|
||||
setPhrasesList(data)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPhrases()
|
||||
}, [])
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingPhrase(null)
|
||||
setFormData({ title: '', content: '' })
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEdit = (phrase: QuickPhrase) => {
|
||||
setEditingPhrase(phrase)
|
||||
setFormData({ title: phrase.title, content: phrase.content })
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await QuickPhraseService.delete(id)
|
||||
await loadPhrases()
|
||||
}
|
||||
|
||||
const handleModalOk = async () => {
|
||||
if (!formData.title.trim() || !formData.content.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (editingPhrase) {
|
||||
await QuickPhraseService.update(editingPhrase.id, formData)
|
||||
} else {
|
||||
await QuickPhraseService.add(formData)
|
||||
}
|
||||
setIsModalOpen(false)
|
||||
await loadPhrases()
|
||||
}
|
||||
|
||||
const handleUpdateOrder = async (newPhrases: QuickPhrase[]) => {
|
||||
setPhrasesList(newPhrases)
|
||||
await QuickPhraseService.updateOrder(newPhrases)
|
||||
}
|
||||
|
||||
const reversedPhrases = [...phrasesList].reverse()
|
||||
|
||||
return (
|
||||
<SettingContainer>
|
||||
<SettingGroup style={{ marginBottom: 0 }}>
|
||||
<SettingTitle>
|
||||
{t('settings.quickPhrase.title')}
|
||||
<Button type="text" icon={<PlusOutlined />} onClick={handleAdd} />
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<QuickPhraseList>
|
||||
<DragableList
|
||||
list={reversedPhrases}
|
||||
onUpdate={(newPhrases) => handleUpdateOrder([...newPhrases].reverse())}
|
||||
style={{ paddingBottom: dragging ? '34px' : 0 }}
|
||||
onDragStart={() => setDragging(true)}
|
||||
onDragEnd={() => setDragging(false)}>
|
||||
{(phrase) => (
|
||||
<FileItem
|
||||
key={phrase.id}
|
||||
fileInfo={{
|
||||
name: phrase.title,
|
||||
ext: '.txt',
|
||||
extra: phrase.content,
|
||||
actions: (
|
||||
<Flex gap={4} style={{ opacity: 0.6 }}>
|
||||
<Button key="edit" type="text" icon={<EditOutlined />} onClick={() => handleEdit(phrase)} />
|
||||
<Popconfirm
|
||||
title={t('settings.quickPhrase.delete')}
|
||||
description={t('settings.quickPhrase.deleteConfirm')}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => handleDelete(phrase.id)}
|
||||
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
|
||||
<Button key="delete" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DragableList>
|
||||
</QuickPhraseList>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<Modal
|
||||
title={editingPhrase ? t('settings.quickPhrase.edit') : t('settings.quickPhrase.add')}
|
||||
open={isModalOpen}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
width={520}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<div>
|
||||
<Label>{t('settings.quickPhrase.titleLabel')}</Label>
|
||||
<Input
|
||||
placeholder={t('settings.quickPhrase.titlePlaceholder')}
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('settings.quickPhrase.contentLabel')}</Label>
|
||||
<TextArea
|
||||
placeholder={t('settings.quickPhrase.contentPlaceholder')}
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
rows={6}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const Label = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
|
||||
const QuickPhraseList = styled.div`
|
||||
width: 100%;
|
||||
height: calc(100vh - 162px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
export default QuickPhraseSettings
|
||||
@ -8,7 +8,8 @@ import {
|
||||
MacCommandOutlined,
|
||||
RocketOutlined,
|
||||
SaveOutlined,
|
||||
SettingOutlined
|
||||
SettingOutlined,
|
||||
ThunderboltOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
@ -29,6 +30,7 @@ import { McpSettingsNavbar } from './MCPSettings/McpSettingsNavbar'
|
||||
import MiniAppSettings from './MiniappSettings/MiniAppSettings'
|
||||
import ProvidersList from './ProviderSettings'
|
||||
import QuickAssistantSettings from './QuickAssistantSettings'
|
||||
import QuickPhraseSettings from './QuickPhraseSettings'
|
||||
import ShortcutSettings from './ShortcutSettings'
|
||||
import WebSearchSettings from './WebSearchSettings'
|
||||
|
||||
@ -108,6 +110,12 @@ const SettingsPage: FC = () => {
|
||||
{t('settings.quickAssistant.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/quickPhrase">
|
||||
<MenuItem className={isRoute('/settings/quickPhrase')}>
|
||||
<ThunderboltOutlined />
|
||||
{t('settings.quickPhrase.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/data">
|
||||
<MenuItem className={isRoute('/settings/data')}>
|
||||
<SaveOutlined />
|
||||
@ -134,6 +142,7 @@ const SettingsPage: FC = () => {
|
||||
<Route path="quickAssistant" element={<QuickAssistantSettings />} />
|
||||
<Route path="data/*" element={<DataSettings />} />
|
||||
<Route path="about" element={<AboutSettings />} />
|
||||
<Route path="quickPhrase" element={<QuickPhraseSettings />} />
|
||||
</Routes>
|
||||
</SettingContent>
|
||||
</ContentContainer>
|
||||
|
||||
68
src/renderer/src/services/QuickPhraseService.ts
Normal file
68
src/renderer/src/services/QuickPhraseService.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import db from '@renderer/databases'
|
||||
import { QuickPhrase } from '@renderer/types'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export class QuickPhraseService {
|
||||
static async getAll(): Promise<QuickPhrase[]> {
|
||||
const phrases = await db.quick_phrases.toArray()
|
||||
return phrases.sort((a, b) => (b.order ?? 0) - (a.order ?? 0))
|
||||
}
|
||||
|
||||
static async add(data: Pick<QuickPhrase, 'title' | 'content'>): Promise<QuickPhrase> {
|
||||
const now = Date.now()
|
||||
const phrases = await this.getAll()
|
||||
|
||||
await Promise.all(
|
||||
phrases.map((phrase) =>
|
||||
db.quick_phrases.update(phrase.id, {
|
||||
order: (phrase.order ?? 0) + 1
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const phrase: QuickPhrase = {
|
||||
id: uuidv4(),
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
order: 0
|
||||
}
|
||||
|
||||
await db.quick_phrases.add(phrase)
|
||||
return phrase
|
||||
}
|
||||
|
||||
static async update(id: string, data: Pick<QuickPhrase, 'title' | 'content'>): Promise<void> {
|
||||
await db.quick_phrases.update(id, {
|
||||
...data,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
static async delete(id: string): Promise<void> {
|
||||
await db.quick_phrases.delete(id)
|
||||
const phrases = await this.getAll()
|
||||
await Promise.all(
|
||||
phrases.map((phrase, index) =>
|
||||
db.quick_phrases.update(phrase.id, {
|
||||
order: phrases.length - 1 - index
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static async updateOrder(phrases: QuickPhrase[]): Promise<void> {
|
||||
const now = Date.now()
|
||||
await Promise.all(
|
||||
phrases.map((phrase, index) =>
|
||||
db.quick_phrases.update(phrase.id, {
|
||||
order: phrases.length - 1 - index,
|
||||
updatedAt: now
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default QuickPhraseService
|
||||
@ -404,3 +404,12 @@ export interface MCPToolResponse {
|
||||
status: string // 'invoking' | 'done'
|
||||
response?: any
|
||||
}
|
||||
|
||||
export interface QuickPhrase {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
order?: number
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user