From fa6157b61003e7ddca46f43f4e0c9243c4ffa5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=98=AD?= <81747598+lan-yonghui@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:00:26 +0800 Subject: [PATCH] feat: enhance AI search functionality with response language support (#12460) --- agent/app/api/v2/file.go | 3 + agent/app/dto/request/file.go | 25 +-- agent/app/service/file.go | 10 +- agent/utils/files/ai_search_llm.go | 55 +++++- frontend/src/api/interface/file.ts | 1 + frontend/src/api/modules/files.ts | 2 +- .../{ => ai-search}/file-ai-search-drawer.vue | 170 +++++++++--------- .../file-management/code-editor/index.vue | 50 +++++- .../src/views/host/file-management/index.vue | 10 +- 9 files changed, 213 insertions(+), 113 deletions(-) rename frontend/src/views/host/file-management/{ => ai-search}/file-ai-search-drawer.vue (85%) diff --git a/agent/app/api/v2/file.go b/agent/app/api/v2/file.go index 2f64804a7..8a90d6ca9 100644 --- a/agent/app/api/v2/file.go +++ b/agent/app/api/v2/file.go @@ -63,6 +63,9 @@ func (b *BaseApi) FileAISearch(c *gin.Context) { if err := helper.CheckBindAndValidate(&req, c); err != nil { return } + if strings.TrimSpace(req.ResponseLanguage) == "" { + req.ResponseLanguage = strings.TrimSpace(c.GetHeader("Accept-Language")) + } res, err := fileService.AISearch(req) if err != nil { helper.InternalServer(c, err) diff --git a/agent/app/dto/request/file.go b/agent/app/dto/request/file.go index b04f6d5b1..b87f8e030 100644 --- a/agent/app/dto/request/file.go +++ b/agent/app/dto/request/file.go @@ -10,18 +10,19 @@ type FileOption struct { } type FileAISearch struct { - Path string `json:"path" validate:"required"` - Query string `json:"query" validate:"required"` - ContainSub *bool `json:"containSub,omitempty"` - MaxItems int `json:"maxItems" validate:"omitempty,min=1,max=2000"` - MatchCase bool `json:"matchCase"` - WholeWord bool `json:"wholeWord"` - UseRegex bool `json:"useRegex"` - Extensions []string `json:"extensions,omitempty"` - MinSize int64 `json:"minSize"` - MaxSize int64 `json:"maxSize"` - ModifiedAfter string `json:"modifiedAfter,omitempty"` - ModifiedBefore string `json:"modifiedBefore,omitempty"` + Path string `json:"path" validate:"required"` + Query string `json:"query" validate:"required"` + ResponseLanguage string `json:"responseLanguage,omitempty"` + ContainSub *bool `json:"containSub,omitempty"` + MaxItems int `json:"maxItems" validate:"omitempty,min=1,max=2000"` + MatchCase bool `json:"matchCase"` + WholeWord bool `json:"wholeWord"` + UseRegex bool `json:"useRegex"` + Extensions []string `json:"extensions,omitempty"` + MinSize int64 `json:"minSize"` + MaxSize int64 `json:"maxSize"` + ModifiedAfter string `json:"modifiedAfter,omitempty"` + ModifiedBefore string `json:"modifiedBefore,omitempty"` MaxScanFiles int `json:"maxScanFiles"` MaxFileBytes int64 `json:"maxFileBytes"` diff --git a/agent/app/service/file.go b/agent/app/service/file.go index 74ba8b355..a306dbf97 100644 --- a/agent/app/service/file.go +++ b/agent/app/service/file.go @@ -1167,9 +1167,15 @@ func (f *FileService) AISearch(req request.FileAISearch) (*response.FileAISearch defer cancel() llmMaxOut := searchOpts.LlmMaxOutputTokens - summary, usage, err := files.RunFileAISearchLLM(runCtx, cfg, clientTimeout, root, query, llmItems, truncated, preFiltered, contentHits, scannedFiles, hitsTrunc, matchDesc, searchOpts.ContentHitsPromptMaxBytes, llmMaxOut) + summary, usage, err := files.RunFileAISearchLLM(runCtx, cfg, clientTimeout, root, query, req.ResponseLanguage, llmItems, truncated, preFiltered, contentHits, scannedFiles, hitsTrunc, matchDesc, searchOpts.ContentHitsPromptMaxBytes, llmMaxOut) if err != nil { - return nil, err + result.Mode = "grep" + result.Summary = "" + result.Duration = time.Since(start).Round(time.Millisecond).String() + if errors.Is(err, context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout") { + return result, nil + } + return result, nil } result.Summary = summary result.PromptTokens = usage.PromptTokens diff --git a/agent/utils/files/ai_search_llm.go b/agent/utils/files/ai_search_llm.go index 886666a7d..65ea99f07 100644 --- a/agent/utils/files/ai_search_llm.go +++ b/agent/utils/files/ai_search_llm.go @@ -11,24 +11,33 @@ import ( const fileAISearchMaxPathRunes = 240 -func buildFileAISearchSystemPrompt() string { - return strings.Join([]string{ +func buildFileAISearchSystemPrompt(responseLanguage string) string { + lines := []string{ "You are a file browser assistant for a server panel.", "Answer using Markdown (headings, bullet lists).", "Only mention files and directories that appear in the provided inventory. Do not invent paths.", "If a \"Content line matches\" section is present, you may reference those lines and numbers only; never invent line numbers or snippets that are not listed there.", "If the inventory was truncated or incomplete, say so and suggest narrowing the directory or increasing limits.", "Group or rank results by relevance to the user's question when helpful.", - "Prefer responding in the same language as the user's query (e.g. Chinese if the query is in Chinese).", - }, "\n") + } + if lang := normalizeFileAISearchLanguage(responseLanguage); lang != "" { + lines = append(lines, "Respond in "+lang+".") + } else { + lines = append(lines, "Prefer responding in the same language as the user's query (e.g. Chinese if the query is in Chinese).") + } + return strings.Join(lines, "\n") } -func buildFileAISearchUserPrompt(root, query string, items []AISearchInventoryItem, truncated, preFiltered bool, contentHits []FileAIContentHit, contentScannedFiles int, contentHitsTruncated bool, matchDesc string, promptHitMaxBytes int) string { +func buildFileAISearchUserPrompt(root, query, responseLanguage string, items []AISearchInventoryItem, truncated, preFiltered bool, contentHits []FileAIContentHit, contentScannedFiles int, contentHitsTruncated bool, matchDesc string, promptHitMaxBytes int) string { var b strings.Builder b.WriteString("User question:\n") b.WriteString(strings.TrimSpace(query)) b.WriteString("\n\nRoot directory:\n") b.WriteString(root) + if lang := normalizeFileAISearchLanguage(responseLanguage); lang != "" { + b.WriteString("\n\nPanel reply language:\n") + b.WriteString(lang) + } b.WriteString("\n\nInventory notes:\n") if truncated { b.WriteString("- Listing was truncated; not all files under the root were included.\n") @@ -80,7 +89,7 @@ func buildFileAISearchUserPrompt(root, query string, items []AISearchInventoryIt return b.String() } -func RunFileAISearchLLM(ctx context.Context, cfg terminalai.GeneratorConfig, clientTimeout time.Duration, root, query string, items []AISearchInventoryItem, truncated, preFiltered bool, contentHits []FileAIContentHit, contentScannedFiles int, contentHitsTruncated bool, matchDesc string, promptHitMaxBytes, llmMaxOutputTokens int) (string, terminalai.ResponseUsage, error) { +func RunFileAISearchLLM(ctx context.Context, cfg terminalai.GeneratorConfig, clientTimeout time.Duration, root, query, responseLanguage string, items []AISearchInventoryItem, truncated, preFiltered bool, contentHits []FileAIContentHit, contentScannedFiles int, contentHitsTruncated bool, matchDesc string, promptHitMaxBytes, llmMaxOutputTokens int) (string, terminalai.ResponseUsage, error) { timeout := clientTimeout if timeout <= 0 { timeout = 2 * time.Minute @@ -109,8 +118,8 @@ func RunFileAISearchLLM(ctx context.Context, cfg terminalai.GeneratorConfig, cli } resp, err := client.ChatCompletion(ctx, terminalai.ChatCompletionRequest{ Messages: []terminalai.ChatMessage{ - {Role: "system", Content: buildFileAISearchSystemPrompt()}, - {Role: "user", Content: buildFileAISearchUserPrompt(root, query, items, truncated, preFiltered, contentHits, contentScannedFiles, contentHitsTruncated, matchDesc, promptHitMaxBytes)}, + {Role: "system", Content: buildFileAISearchSystemPrompt(responseLanguage)}, + {Role: "user", Content: buildFileAISearchUserPrompt(root, query, responseLanguage, items, truncated, preFiltered, contentHits, contentScannedFiles, contentHitsTruncated, matchDesc, promptHitMaxBytes)}, }, MaxTokens: outTokens, }) @@ -123,3 +132,33 @@ func RunFileAISearchLLM(ctx context.Context, cfg terminalai.GeneratorConfig, cli } return summary, resp.Usage, nil } + +func normalizeFileAISearchLanguage(lang string) string { + lang = strings.TrimSpace(strings.ToLower(lang)) + switch { + case lang == "", lang == "*": + return "English" + case strings.HasPrefix(lang, "zh-hant"), strings.HasPrefix(lang, "zh-tw"), strings.HasPrefix(lang, "zh-hk"): + return "Traditional Chinese" + case strings.HasPrefix(lang, "zh"): + return "Simplified Chinese" + case strings.HasPrefix(lang, "en"): + return "English" + case strings.HasPrefix(lang, "ja"): + return "Japanese" + case strings.HasPrefix(lang, "ko"): + return "Korean" + case strings.HasPrefix(lang, "ru"): + return "Russian" + case strings.HasPrefix(lang, "ms"): + return "Malay" + case strings.HasPrefix(lang, "tr"): + return "Turkish" + case strings.HasPrefix(lang, "pt-br"): + return "Brazilian Portuguese" + case strings.HasPrefix(lang, "es"): + return "Spanish" + default: + return lang + } +} diff --git a/frontend/src/api/interface/file.ts b/frontend/src/api/interface/file.ts index f6b268e29..13fdf9c33 100644 --- a/frontend/src/api/interface/file.ts +++ b/frontend/src/api/interface/file.ts @@ -42,6 +42,7 @@ export namespace File { export interface FileAISearchReq { path: string; query: string; + responseLanguage?: string; containSub?: boolean; maxItems?: number; matchCase?: boolean; diff --git a/frontend/src/api/modules/files.ts b/frontend/src/api/modules/files.ts index 1940eda09..63f302846 100644 --- a/frontend/src/api/modules/files.ts +++ b/frontend/src/api/modules/files.ts @@ -11,7 +11,7 @@ export const getFilesList = (params: File.ReqFile) => { }; export const fileAiSearch = (params: File.FileAISearchReq) => { - return http.post('files/ai-search', params, TimeoutEnum.T_5M); + return http.post('files/ai-search', params, TimeoutEnum.T_10M); }; export const getFilesListByNode = (params: File.ReqNodeFile) => { diff --git a/frontend/src/views/host/file-management/file-ai-search-drawer.vue b/frontend/src/views/host/file-management/ai-search/file-ai-search-drawer.vue similarity index 85% rename from frontend/src/views/host/file-management/file-ai-search-drawer.vue rename to frontend/src/views/host/file-management/ai-search/file-ai-search-drawer.vue index 166783443..af1dae38d 100644 --- a/frontend/src/views/host/file-management/file-ai-search-drawer.vue +++ b/frontend/src/views/host/file-management/ai-search/file-ai-search-drawer.vue @@ -137,7 +137,9 @@ @@ -225,114 +227,104 @@
{{ $t('file.aiSearchLimitMaxItems') }} - - - - - + + +
{{ $t('file.aiSearchLimitMaxScan') }} - - - - - + + +
{{ $t('file.aiSearchLimitMaxBytes') }} - - - - - + + +
{{ $t('file.aiSearchLimitHitsPerFile') }} - - - - - + + +
{{ $t('file.aiSearchLimitTotalHits') }} - - - - - + + +
@@ -499,6 +491,7 @@ import { fileAiSearch } from '@/api/modules/files'; import { getAgentFileManageAIInfo, updateAgentFileManageAIInfo } from '@/api/modules/setting'; import { pageAgentAccounts } from '@/api/modules/ai'; import { File } from '@/api/interface/file'; +import { useGlobalStore } from '@/composables/useGlobalStore'; import i18n from '@/lang'; import { MsgSuccess, MsgWarning } from '@/utils/message'; import MkdownEditor from '@/components/mkdown-editor/index.vue'; @@ -522,9 +515,11 @@ const props = defineProps<{ listPath: string; }>(); +const { globalStore } = useGlobalStore(); + const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; - (e: 'pick-directory'): void; + (e: 'pick-directory', path: string): void; (e: 'open-editor', payload: { path: string; initialLine?: number }): void; }>(); @@ -814,6 +809,7 @@ const submitAiSearch = async () => { const payload: File.FileAISearchReq = { path: rootPath, query: q, + responseLanguage: globalStore.language, containSub: aiSearchContainSub.value, matchCase: aiSearchMatchCase.value, wholeWord: aiSearchWholeWord.value, @@ -851,8 +847,12 @@ const submitAiSearch = async () => { } }; -function applyPathFromPicker(path: string) { - aiSearchPath.value = path; +function applyPathFromPicker(path: string | string[]) { + if (Array.isArray(path)) { + aiSearchPath.value = (path[0] || '').trim(); + return; + } + aiSearchPath.value = (path || '').trim(); } function resetAiSearchLimitsToDefaults() { diff --git a/frontend/src/views/host/file-management/code-editor/index.vue b/frontend/src/views/host/file-management/code-editor/index.vue index 22070ec4f..5ae6df779 100644 --- a/frontend/src/views/host/file-management/code-editor/index.vue +++ b/frontend/src/views/host/file-management/code-editor/index.vue @@ -438,14 +438,45 @@ interface TreeNode { } const pendingInitialLine = ref(0); +let lineHighlightDecorationIds: string[] = []; + +const clearPendingLineHighlight = () => { + if (!editor) { + lineHighlightDecorationIds = []; + return; + } + lineHighlightDecorationIds = editor.deltaDecorations(lineHighlightDecorationIds, []); +}; const revealPendingInitialLine = () => { const line = pendingInitialLine.value; if (!editor || line < 1) { return; } - editor.setPosition({ lineNumber: line, column: 1 }); - editor.revealLineInCenter(line); + const model = editor.getModel(); + if (!model) { + return; + } + const targetLine = Math.min(line, model.getLineCount()); + editor.setSelection({ + startLineNumber: targetLine, + startColumn: 1, + endLineNumber: targetLine, + endColumn: 1, + }); + editor.setPosition({ lineNumber: targetLine, column: 1 }); + editor.revealLineInCenter(targetLine); + lineHighlightDecorationIds = editor.deltaDecorations(lineHighlightDecorationIds, [ + { + range: new monaco.Range(targetLine, 1, targetLine, model.getLineMaxColumn(targetLine)), + options: { + isWholeLine: true, + className: 'ai-search-target-line', + linesDecorationsClassName: 'ai-search-target-line-gutter', + }, + }, + ]); + editor.focus(); pendingInitialLine.value = 0; }; @@ -722,6 +753,7 @@ const handleClose = () => { fileTabs.value = []; isEdit.value = false; if (editor) { + clearPendingLineHighlight(); editor.dispose(); } em('close', open.value); @@ -942,6 +974,12 @@ const acceptParams = async (props: EditProps) => { saveTabsToStorage(); nextTick(() => { if (editor) { + editor.setValue(form.value.content); + const model = editor.getModel(); + if (model) { + monaco.editor.setModelLanguage(model, config.language); + } + isEdit.value = false; revealPendingInitialLine(); } }); @@ -1427,4 +1465,12 @@ defineExpose({ acceptParams }); :deep(.el-input__inner:focus) { outline: none !important; } + +:deep(.monaco-editor .ai-search-target-line) { + background-color: rgba(64, 158, 255, 0.14); +} + +:deep(.monaco-editor .ai-search-target-line-gutter) { + border-left: 3px solid var(--el-color-primary); +} diff --git a/frontend/src/views/host/file-management/index.vue b/frontend/src/views/host/file-management/index.vue index b2f77691e..ec7fd3ba0 100644 --- a/frontend/src/views/host/file-management/index.vue +++ b/frontend/src/views/host/file-management/index.vue @@ -666,7 +666,7 @@ ref="aiSearchDrawerRef" v-model="aiSearchDrawerVisible" :list-path="req.path" - @pick-directory="fileRef.acceptParams({ dir: false, multiple: true })" + @pick-directory="openAiSearchPathPicker" @open-editor="onAiSearchOpenEditor" /> @@ -733,7 +733,7 @@ import Preview from './preview/index.vue'; import TextPreview from './text-preview/index.vue'; import VscodeOpenDialog from '@/components/vscode-open/index.vue'; import Convert from './convert/index.vue'; -import FileAiSearchDrawer from './file-ai-search-drawer.vue'; +import FileAiSearchDrawer from './ai-search/file-ai-search-drawer.vue'; import FileShare from './share/index.vue'; import { debounce } from 'lodash-es'; import TerminalDialog from './terminal/index.vue'; @@ -837,10 +837,14 @@ const openAiSearchDrawer = () => { aiSearchDrawerVisible.value = true; }; -const getSearchPath = (path: string) => { +const getSearchPath = (path: string | string[]) => { aiSearchDrawerRef.value?.applyPathFromPicker(path); }; +const openAiSearchPathPicker = (path?: string) => { + fileRef.value.acceptParams({ path: path || req.path, dir: true, multiple: false }); +}; + const createRef = ref(); const roleRef = ref(); const detailRef = ref();