diff --git a/agent/app/api/v2/file.go b/agent/app/api/v2/file.go index 2f64804a7e46..8a90d6ca9cb4 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 b04f6d5b1798..b87f8e0302e4 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 74ba8b35573e..a306dbf979b5 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 886666a7d0c0..65ea99f07b72 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 f6b268e29670..13fdf9c330be 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 1940eda0994f..63f302846c2e 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 1667834433f2..af1dae38d4b9 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 22070ec4fcc6..5ae6df77966e 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 b2f77691e7fe..ec7fd3ba0a75 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();