Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'

import { defaultFilterFn } from '../defaultStrategies'
import type { QuickPanelListItem } from '../types'

const fuzzyRegex = /unused/i

function matches(item: QuickPanelListItem, searchText: string) {
return defaultFilterFn(item, searchText, fuzzyRegex, new WeakMap())
}

describe('default QuickPanel filtering', () => {
it('keeps filterText additive with label, description, and search aliases', () => {
const item: QuickPanelListItem = {
label: 'Visible label',
description: 'Readable description',
filterText: 'explicit-field',
searchAliases: ['Hidden alias'],
icon: 'icon'
}

expect(matches(item, 'explicit')).toBe(true)
expect(matches(item, 'visible')).toBe(true)
expect(matches(item, 'readable')).toBe(true)
expect(matches(item, 'hidden')).toBe(true)
})
})
32 changes: 16 additions & 16 deletions src/renderer/components/QuickPanel/defaultStrategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,28 @@ import * as tinyPinyin from 'tiny-pinyin'

import type { QuickPanelFilterFn, QuickPanelListItem, QuickPanelSortFn } from './types'

/**
* Concatenates all textual search fields into a single haystack for substring /
* pinyin matching. `filterText` is additive here so the shared QuickPanel
* strategy keeps matching the visible label and description by default.
*/
function buildItemHaystack(item: QuickPanelListItem): string {
const parts: string[] = []
if (item.filterText) parts.push(item.filterText)
if (typeof item.label === 'string') parts.push(item.label)
if (typeof item.description === 'string') parts.push(item.description)
if (item.searchAliases) parts.push(...item.searchAliases)
return parts.join(' ')
}

/**
* Default filter function
* Implements standard filtering logic with pinyin support
*/
export const defaultFilterFn: QuickPanelFilterFn = (item, searchText, fuzzyRegex, pinyinCache) => {
if (!searchText) return true

let filterText = item.filterText || ''
if (typeof item.label === 'string') {
filterText += item.label
}
if (typeof item.description === 'string') {
filterText += item.description
}

const filterText = buildItemHaystack(item)
const lowerFilterText = filterText.toLowerCase()
const lowerSearchText = searchText.toLowerCase()

Expand Down Expand Up @@ -47,14 +54,7 @@ export const defaultFilterFn: QuickPanelFilterFn = (item, searchText, fuzzyRegex
* Higher score = better match
*/
const calculateMatchScore = (item: QuickPanelListItem, searchText: string): number => {
let filterText = item.filterText || ''
if (typeof item.label === 'string') {
filterText += item.label
}
if (typeof item.description === 'string') {
filterText += item.description
}

const filterText = buildItemHaystack(item)
const lowerFilterText = filterText.toLowerCase()
const lowerSearchText = searchText.toLowerCase()

Expand Down
8 changes: 6 additions & 2 deletions src/renderer/components/QuickPanel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,14 @@ export type QuickPanelListItem = {
label: React.ReactNode | string
description?: React.ReactNode | string
/**
* Since title and description can be ReactNode values, provide separate text
* for search filtering. This can combine the title and description strings.
* Extra searchable text for items whose visible label/description are not
* enough or are not plain strings. The default filter treats this as additive
* with string labels and descriptions; custom filters may choose narrower
* semantics.
*/
filterText?: string
/** Extra searchable aliases that are not rendered in the panel. */
searchAliases?: readonly string[]
icon: React.ReactNode | string
suffix?: React.ReactNode | string
isSelected?: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,30 +129,54 @@ describe('createUnifiedQuickPanelOpenOptions', () => {
expect(options.sortFn!(reversedItems, '')).toEqual(reversedItems)
})

it('filters by filterText substring and pinyin, without loose fuzzy subsequence', () => {
const options = createUnifiedQuickPanelOpenOptions([], {
quickPanel,
additionalItems: [
{ id: 'skill:pdf', label: 'pdf', description: 'Read and analyze PDFs', filterText: 'pdf', icon: 'skill' }
it('filters by explicit text, search aliases, pinyin, and pinyin initials without loose fuzzy subsequence', () => {
const options = createUnifiedQuickPanelOpenOptions(
[
{
id: 'web-search',
kind: 'command',
label: '网络搜索',
icon: 'search',
sources: ['root-panel'],
searchAliases: ['Web Search', 'Online Search']
}
],
resourceItems: [{ id: 'quick-phrases', label: '提示词管理', icon: 'phrase' }]
})
{
quickPanel,
additionalItems: [
{
id: 'skill:pdf',
label: 'pdf',
description: 'Read and analyze PDFs',
filterText: 'pdf',
icon: 'skill'
}
],
resourceItems: [{ id: 'quick-phrases', label: '提示词管理', icon: 'phrase' }]
}
)

const filterFn = options.filterFn!
const fuzzyRegex = /s.*l/i
const pinyinCache = new WeakMap<QuickPanelListItem, string>()
const skill = options.list.find((item) => item.label === 'pdf')!
const quickPhrases = options.list.find((item) => item.label === '提示词管理')!
const webSearch = options.list.find((item) => item.label === '网络搜索')!

// Skill matches its name...
// Skills keep their explicit root-panel search field and do not match descriptions.
expect(filterFn(skill, 'pdf', fuzzyRegex, pinyinCache)).toBe(true)
// ...but not its description (name-only).
expect(filterFn(skill, 'analyze', fuzzyRegex, pinyinCache)).toBe(false)

// Chinese row matches by substring and pinyin substring...
// Chinese row matches by substring, pinyin substring, and pinyin initial substring...
expect(filterFn(quickPhrases, '提示词', fuzzyRegex, pinyinCache)).toBe(true)
expect(filterFn(quickPhrases, 'tishi', fuzzyRegex, pinyinCache)).toBe(true)
// ...but not by a loose fuzzy subsequence of its pinyin (tiShiCiGuanLi).
expect(filterFn(quickPhrases, 'tscgl', fuzzyRegex, pinyinCache)).toBe(true)

// Launcher rows with filterText still match hidden English aliases and visible Chinese labels by initials.
expect(filterFn(webSearch, 'web', fuzzyRegex, pinyinCache)).toBe(true)
expect(filterFn(webSearch, 'online', fuzzyRegex, pinyinCache)).toBe(true)
expect(filterFn(webSearch, 'wlss', fuzzyRegex, pinyinCache)).toBe(true)
// ...but not by a loose fuzzy subsequence of its pinyin or initials.
expect(filterFn(quickPhrases, 'sl', fuzzyRegex, pinyinCache)).toBe(false)
})

Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/composer/quickPanel/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export function createComposerSuggestionQuickPanelItem(
icon: item.icon,
suffix: item.suffix,
filterText: item.filterText,
searchAliases: item.searchAliases,
isSelected: item.selected,
isMenu: item.isMenu,
disabled: item.disabled,
Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/composer/quickPanel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
hasComposerQuickPanelTriggerBoundary,
ROOT_QUICK_PANEL_ALLOWED_PREFIXES
} from './bridge'
export { getQuickPanelSearchAliases } from './searchAliases'
export {
COMPOSER_SUPPRESS_SUGGESTION_META,
type ComposerSuggestionActiveChangeOptions,
Expand Down
15 changes: 15 additions & 0 deletions src/renderer/components/composer/quickPanel/searchAliases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { TFunction } from 'i18next'

export function getQuickPanelSearchAliases(t: TFunction, key: string, extraAliases: readonly string[] = []): string[] {
const aliases = new Set<string>()
const englishText = t(key, { lng: 'en-US', defaultValue: '' })

for (const value of [englishText, ...extraAliases]) {
if (typeof value !== 'string') continue
const alias = value.trim()
if (!alias) continue
aliases.add(alias)
}

return Array.from(aliases)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface ComposerSuggestionItem {
isMenu?: boolean
suffix?: ReactNode | string
query?: string
searchAliases?: readonly string[]
command: (options: { editor: Editor; range: Range; item: ComposerSuggestionItem; query: string }) => void
}

Expand Down
24 changes: 17 additions & 7 deletions src/renderer/components/composer/quickPanel/unifiedPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,20 +142,29 @@ const sortUnifiedQuickPanelItems: QuickPanelSortFn = (items, searchText) => {

function getUnifiedQuickPanelMatchText(item: QuickPanelListItem) {
// `filterText`, when set, is the authoritative search field for the item
// (e.g. skills set it to their name only). Otherwise fall back to the visible
// label + description so items without an explicit search field stay searchable.
if (item.filterText) return item.filterText
// (e.g. skills set it to their name only). Hidden aliases may still expand search.
if (item.filterText) {
return item.searchAliases ? `${item.filterText} ${item.searchAliases.join(' ')}` : item.filterText
}

const parts: string[] = []
if (typeof item.label === 'string') parts.push(item.label)
if (typeof item.description === 'string') parts.push(item.description)
if (item.searchAliases) parts.push(...item.searchAliases)
return parts.join(' ')
}

function getPinyinSearchText(matchText: string) {
const pinyinWords = tinyPinyin.convertToPinyin(matchText, ' ', true).toLowerCase().split(/\s+/).filter(Boolean)
const pinyinText = pinyinWords.join('')
const pinyinInitials = pinyinWords.map((word) => word[0] ?? '').join('')
return `${pinyinText} ${pinyinInitials}`
}

/**
* Root panel filter: substring match, plus pinyin substring for Chinese text.
* Intentionally avoids the default loose fuzzy subsequence matching so unrelated
* rows (e.g. Quick Phrases) don't surface for a query typed for another item.
* Root panel filter: substring match, plus pinyin and pinyin-initial substring
* matching for Chinese text. Intentionally avoids loose fuzzy subsequence matching
* so unrelated rows (e.g. Quick Phrases) don't surface for another item's query.
*/
const filterUnifiedQuickPanelItems: QuickPanelFilterFn = (item, searchText, _fuzzyRegex, pinyinCache) => {
if (!searchText) return true
Expand All @@ -169,7 +178,7 @@ const filterUnifiedQuickPanelItems: QuickPanelFilterFn = (item, searchText, _fuz
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(matchText)) {
let pinyinText = pinyinCache.get(item)
if (pinyinText === undefined) {
pinyinText = tinyPinyin.convertToPinyin(matchText, '', true).toLowerCase()
pinyinText = getPinyinSearchText(matchText)
pinyinCache.set(item, pinyinText)
}
return pinyinText.includes(query)
Expand Down Expand Up @@ -251,6 +260,7 @@ function createUnifiedPanelListItem(
description: getLauncherDescription(launcher),
icon: launcher.icon,
suffix: launcher.suffix,
searchAliases: launcher.searchAliases,
isSelected: launcher.active,
isMenu: launcher.kind === 'panel' || launcher.kind === 'group' || children.length > 0,
disabled: launcher.disabled,
Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/composer/toolLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface ComposerToolLauncher {
description?: ReactNode | string
tooltip?: ReactNode | string
disabledReason?: ReactNode | string
searchAliases?: readonly string[]
icon: ReactNode | string
suffix?: ReactNode | string
active?: boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getQuickPanelSearchAliases } from '@renderer/components/composer/quickPanel'
import type { ToolLauncherApi } from '@renderer/components/composer/tools/types'
import { filterSupportedFiles } from '@renderer/utils/file'
import { type ComposerAttachment, toComposerAttachments } from '@renderer/utils/message/composerAttachment'
Expand Down Expand Up @@ -72,6 +73,7 @@ const useAttachmentToolController = ({ launcher, couldAddImageFile, extensions,
order: 10,
label: t('chat.input.upload.attachment'),
description: '',
searchAliases: getQuickPanelSearchAliases(t, 'chat.input.upload.attachment', ['upload attachment']),
tooltip: isDocumentOnly ? t('chat.input.upload.image_not_supported') : undefined,
icon: <Paperclip />,
suffix: isDocumentOnly ? t('chat.input.upload.document_only') : undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ComposerPanelSymbol } from '@renderer/components/composer/quickPanel'
import { getQuickPanelSearchAliases } from '@renderer/components/composer/quickPanel'
import type { ToolLauncherApi } from '@renderer/components/composer/tools/types'
import {
type QuickPanelCallBackOptions,
Expand Down Expand Up @@ -133,7 +134,7 @@
closeKnowledgeBasePanelOnNextInput({ context, inputAdapter })
}
}))
}, [closeKnowledgeBasePanelOnNextInput, configuredBases, language, selectedBaseIds])

Check warning on line 137 in src/renderer/components/composer/tools/components/KnowledgeBaseButton.tsx

View workflow job for this annotation

GitHub Actions / basic-checks

React Hook useCallback has an unnecessary dependency: 'language'. Either exclude it or remove the dependency array

const knowledgeBaseItems = useMemo(() => buildKnowledgeBaseItems(), [buildKnowledgeBaseItems])

Expand Down Expand Up @@ -189,6 +190,7 @@
order: 40,
label: t('chat.input.knowledge_base'),
description: resolvedDisabledReason ?? '',
searchAliases: getQuickPanelSearchAliases(t, 'chat.input.knowledge_base', ['knowledge base']),
disabledReason: resolvedDisabledReason,
icon: <FileSearch />,
active: isEnabled,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMutation, useQuery } from '@data/hooks/useDataApi'
import { loggerService } from '@logger'
import { ComposerPanelSymbol } from '@renderer/components/composer/quickPanel'
import { getQuickPanelSearchAliases } from '@renderer/components/composer/quickPanel'
import type { ToolLauncherApi } from '@renderer/components/composer/tools/types'
import {
type QuickPanelCallBackOptions,
Expand Down Expand Up @@ -210,6 +211,7 @@ const useQuickPhrasesToolController = ({ launcher, setInputValue }: Props) => {
order: 70,
label: t('settings.prompts.title'),
description: '',
searchAliases: getQuickPanelSearchAliases(t, 'settings.prompts.title'),
icon: <Zap />,
action: ({ parentPanel, queryAnchor, triggerInfo }) => {
openQuickPanel(parentPanel, queryAnchor, triggerInfo)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getQuickPanelSearchAliases } from '@renderer/components/composer/quickPanel'
import type { ToolLauncherApi } from '@renderer/components/composer/tools/types'
import {
MdiLightbulbAutoOutline,
Expand Down Expand Up @@ -183,6 +184,10 @@ const useThinkingToolController = ({
order: 60,
label: t('assistants.settings.reasoning_effort.label'),
description: '',
searchAliases: getQuickPanelSearchAliases(t, 'assistants.settings.reasoning_effort.label', [
'think',
'reasoning effort'
]),
disabledReason,
icon: ThinkingIcon({ option: currentReasoningEffort }),
active: isReasoningConfigurable && isThinkingEnabled,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Tooltip } from '@cherrystudio/ui'
import ActionIconButton from '@renderer/components/ActionIconButton'
import { getQuickPanelSearchAliases } from '@renderer/components/composer/quickPanel'
import type { ToolLauncherApi } from '@renderer/components/composer/tools/types'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useProvider } from '@renderer/hooks/useProvider'
Expand Down Expand Up @@ -145,6 +146,7 @@ const useWebSearchToolController = ({ assistantId, launcher }: Props) => {
order: 30,
label: t('chat.input.web_search.label'),
description: '',
searchAliases: getQuickPanelSearchAliases(t, 'chat.input.web_search.label', ['search']),
icon,
active: enableWebSearch,
disabled: isDisabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ vi.mock('@renderer/utils/file', () => ({
filterSupportedFiles: vi.fn(async (files) => files)
}))

const t = (key: string) => {
const translations: Record<string, string> = {
const t = (key: string, options?: { lng?: string; defaultValue?: string }) => {
const englishTranslations: Record<string, string> = {
'chat.input.upload.attachment': 'Upload attachment',
'chat.input.upload.document_only': 'Documents only',
'chat.input.upload.image_not_supported': 'This model does not support image uploads. Documents only.'
}

return translations[key] ?? key
if (options?.lng === 'en-US') return englishTranslations[key] ?? options.defaultValue ?? key
return key
}

vi.mock('react-i18next', () => ({
Expand Down Expand Up @@ -83,9 +84,10 @@ describe('AttachmentToolRuntime', () => {
expect(attachmentLauncher).toMatchObject({
id: 'attachment',
sources: ['popover'],
label: 'Upload attachment',
suffix: 'Documents only',
tooltip: 'This model does not support image uploads. Documents only.'
label: 'chat.input.upload.attachment',
searchAliases: expect.arrayContaining(['Upload attachment']),
suffix: 'chat.input.upload.document_only',
tooltip: 'chat.input.upload.image_not_supported'
})
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getQuickPanelSearchAliases } from '@renderer/components/composer/quickPanel'
import { defineTool, TopicType } from '@renderer/components/composer/tools/types'
import { isGenerateImageModel } from '@renderer/utils/model'
import { Image } from 'lucide-react'
Expand All @@ -22,6 +23,7 @@ const useGenerateImageToolController = (context) => {
order: 20,
label: t('chat.input.generate_image'),
description: '',
searchAliases: getQuickPanelSearchAliases(t, 'chat.input.generate_image', ['generate image']),
disabledReason: t('chat.input.generate_image_not_supported'),
icon: <Image size={18} />,
active: enabled && isSupported,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getQuickPanelSearchAliases } from '@renderer/components/composer/quickPanel'
import { defineTool, type ToolRenderContext, TopicType } from '@renderer/components/composer/tools/types'
import { useAgent } from '@renderer/hooks/agent/useAgent'
import { useUpdateAgent } from '@renderer/hooks/agent/useAgent'
Expand Down Expand Up @@ -79,6 +80,7 @@ const usePermissionModeToolController = (context: PermissionModeContext) => {
order: 80,
label: t('agent.settings.permissionMode.title', 'Permission Mode'),
description: '',
searchAliases: getQuickPanelSearchAliases(t, 'agent.settings.permissionMode.title'),
icon: getPermissionModeIcon(currentMode),
suffix: tooltipTitle,
submenu: modeSubmenu
Expand Down
Loading
Loading