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:
Teo 2025-04-05 16:05:28 +08:00 committed by GitHub
parent ea059d5517
commit f9be0e0d26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1784 additions and 701 deletions

View File

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

View 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
}

View File

@ -0,0 +1,4 @@
export * from './hook'
export * from './provider'
export * from './types'
export * from './view'

View 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 }

View 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
}

View 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;
`

View File

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

View File

@ -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",

View File

@ -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": "終了",

View File

@ -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": "Выйти",

View File

@ -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": "翻译中...",

View File

@ -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": "結束",

View File

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

View File

@ -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}
/>
<QuickPanelProvider>
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
</QuickPanelProvider>
</Main>
{topicPosition === 'right' && showTopics && (
<Tabs

View File

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

View File

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

View File

@ -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,29 +254,162 @@ 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) {
if (!textArea) return
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
}
}
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 (event.key === 'Escape' && isMentionPopupOpen) {
setIsMentionPopupOpen(false)
if (startIndex !== -1) {
const endIndex = startIndex + match![0].length
textArea.setSelectionRange(startIndex, endIndex)
return
}
}
if (autoTranslateWithSpace) {
if (event.key === ' ') {
@ -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);

View File

@ -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) => ({
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,
value: base.id
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 type="text" onClick={handleOpenQuickPanel} disabled={disabled}>
<FileSearchOutlined />
</ToolbarButton>
</Popover>
</Tooltip>
)
}
const SelectorContainer = styled.div`
max-height: 300px;
overflow-y: auto;
`
const EmptyMessage = styled.div`
padding: 8px;
`
export default KnowledgeBaseButton

View 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

View File

@ -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}>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<CodeOutlined style={{ color: buttonEnabled ? 'var(--color-primary)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
</Dropdown>
)
}
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

View File

@ -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'])
.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)
}))
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)
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)))
.filter((m) => !isEmbeddingModel(m))
.filter((m) => !isRerankModel(m))
.map((m) => ({
key: getModelUniqId(m),
model: m,
provider: p
provider: p,
isPinned: pinnedModels.includes(getModelUniqId(m))
}))
)
.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>
),
// 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(m.model.id)} size={24}>
{first(m.model.name)}
<Avatar src={getModelLogo(item.model.id)} size={20}>
{first(item.model.name)}
</Avatar>
),
onClick: () => handleModelSelect(m.model)
action: () => onMentionModel(item.model),
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(item.model))
}))
if (pinnedItems.length > 0) {
items.unshift({
key: 'pinned',
label: t('models.pinned'),
type: 'group' as const,
children: pinnedItems
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])
// Remove empty groups
return items.filter((group) => group.children.length > 0)
}, [providers, pinnedModels, t, searchText, togglePin, handleModelSelect])
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])
// Get flattened list of all model items
const flatModelItems = useMemo(() => {
return modelMenuItems.flatMap((group) => group?.children || [])
}, [modelMenuItems])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === '@') {
quickPanel.close()
} else {
openQuickPanel()
}
}, [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)
useImperativeHandle(ref, () => ({
openQuickPanel
}))
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>
)
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}>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<i className="iconfont icon-at" style={{ fontSize: 18 }}></i>
</ToolbarButton>
</Tooltip>
</Dropdown>
)
}
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

View File

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

View 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)

View File

@ -20,6 +20,7 @@ const Container = styled.div`
max-width: 800px;
width: 100%;
margin: 0 auto;
position: relative;
`
export default NarrowLayout

View 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

View File

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

View 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

View File

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