feat: enhance AI search functionality with response language support (#12460)

This commit is contained in:
2026-04-09 19:00:26 +08:00
committed by GitHub
parent 30af26779c
commit fa6157b610
9 changed files with 213 additions and 113 deletions
+3
View File
@@ -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)
+13 -12
View File
@@ -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"`
+8 -2
View File
@@ -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
+47 -8
View File
@@ -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
}
}
+1
View File
@@ -42,6 +42,7 @@ export namespace File {
export interface FileAISearchReq {
path: string;
query: string;
responseLanguage?: string;
containSub?: boolean;
maxItems?: number;
matchCase?: boolean;
+1 -1
View File
@@ -11,7 +11,7 @@ export const getFilesList = (params: File.ReqFile) => {
};
export const fileAiSearch = (params: File.FileAISearchReq) => {
return http.post<File.FileAISearchResult>('files/ai-search', params, TimeoutEnum.T_5M);
return http.post<File.FileAISearchResult>('files/ai-search', params, TimeoutEnum.T_10M);
};
export const getFilesListByNode = (params: File.ReqNodeFile) => {
@@ -137,7 +137,9 @@
</el-checkbox>
</template>
<template #append>
<el-button @click="emit('pick-directory')">
<el-button
@click="emit('pick-directory', (aiSearchPath || props.listPath || '').trim())"
>
<el-icon><Folder /></el-icon>
</el-button>
</template>
@@ -225,114 +227,104 @@
<div class="grid grid-cols-1 items-center gap-x-4 gap-y-3 min-[480px]:grid-cols-2">
<div class="ai-search-field-pair flex min-w-0 items-center gap-2">
<span
class="w-28 shrink-0 text-sm leading-8 text-[var(--el-text-color-secondary)] sm:w-32"
class="w-32 shrink-0 text-sm leading-8 text-[var(--el-text-color-secondary)] sm:w-36"
>
{{ $t('file.aiSearchLimitMaxItems') }}
</span>
<el-tooltip :content="$t('file.aiSearchMaxItems')" placement="top">
<el-input-number
v-model="aiSearchMaxItems"
:min="1"
:max="2000"
:controls="true"
class="min-w-0 flex-1 !max-w-full sm:!w-44"
>
<template #suffix>
<span class="pr-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('file.aiSearchLimitUnitEntries') }}
</span>
</template>
</el-input-number>
</el-tooltip>
<el-input-number
v-model="aiSearchMaxItems"
:min="1"
:max="2000"
:controls="true"
class="min-w-0 flex-1 !max-w-full sm:!w-52"
>
<template #suffix>
<span class="pr-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('file.aiSearchLimitUnitEntries') }}
</span>
</template>
</el-input-number>
</div>
<div class="ai-search-field-pair flex min-w-0 items-center gap-2">
<span
class="w-28 shrink-0 text-sm leading-8 text-[var(--el-text-color-secondary)] sm:w-32"
class="w-32 shrink-0 text-sm leading-8 text-[var(--el-text-color-secondary)] sm:w-36"
>
{{ $t('file.aiSearchLimitMaxScan') }}
</span>
<el-tooltip :content="$t('file.aiSearchMaxScanFiles')" placement="top">
<el-input-number
v-model="aiSearchMaxScanFiles"
:min="1"
:max="500"
:controls="true"
class="min-w-0 flex-1 !max-w-full sm:!w-44"
>
<template #suffix>
<span class="pr-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('file.aiSearchLimitUnitFiles') }}
</span>
</template>
</el-input-number>
</el-tooltip>
<el-input-number
v-model="aiSearchMaxScanFiles"
:min="1"
:max="500"
:controls="true"
class="min-w-0 flex-1 !max-w-full sm:!w-52"
>
<template #suffix>
<span class="pr-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('file.aiSearchLimitUnitFiles') }}
</span>
</template>
</el-input-number>
</div>
<div class="ai-search-field-pair flex min-w-0 items-center gap-2">
<span
class="w-28 shrink-0 text-sm leading-8 text-[var(--el-text-color-secondary)] sm:w-32"
class="w-32 shrink-0 text-sm leading-8 text-[var(--el-text-color-secondary)] sm:w-36"
>
{{ $t('file.aiSearchLimitMaxBytes') }}
</span>
<el-tooltip :content="$t('file.aiSearchMaxFileBytes')" placement="top">
<el-input-number
v-model="aiSearchMaxFileBytes"
:min="1024"
:max="4194304"
:step="1024"
:controls="true"
class="min-w-0 flex-1 !max-w-full sm:!w-52"
>
<template #suffix>
<span class="pr-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('file.aiSearchLimitUnitBytes') }}
</span>
</template>
</el-input-number>
</el-tooltip>
<el-input-number
v-model="aiSearchMaxFileBytes"
:min="1024"
:max="4194304"
:step="1024"
:controls="true"
class="min-w-0 flex-1 !max-w-full sm:!w-52"
>
<template #suffix>
<span class="pr-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('file.aiSearchLimitUnitBytes') }}
</span>
</template>
</el-input-number>
</div>
<div class="ai-search-field-pair flex min-w-0 items-center gap-2">
<span
class="w-28 shrink-0 text-sm leading-8 text-[var(--el-text-color-secondary)] sm:w-32"
class="w-32 shrink-0 text-sm leading-8 text-[var(--el-text-color-secondary)] sm:w-36"
>
{{ $t('file.aiSearchLimitHitsPerFile') }}
</span>
<el-tooltip :content="$t('file.aiSearchMaxHitsPerFile')" placement="top">
<el-input-number
v-model="aiSearchMaxHitsPerFile"
:min="1"
:max="200"
:controls="true"
class="min-w-0 flex-1 !max-w-full sm:!w-44"
>
<template #suffix>
<span class="pr-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('file.aiSearchLimitUnitLines') }}
</span>
</template>
</el-input-number>
</el-tooltip>
<el-input-number
v-model="aiSearchMaxHitsPerFile"
:min="1"
:max="200"
:controls="true"
class="min-w-0 flex-1 !max-w-full sm:!w-52"
>
<template #suffix>
<span class="pr-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('file.aiSearchLimitUnitLines') }}
</span>
</template>
</el-input-number>
</div>
<div class="ai-search-field-pair flex min-w-0 items-center gap-2">
<span
class="w-28 shrink-0 text-sm leading-8 text-[var(--el-text-color-secondary)] sm:w-32"
class="w-32 shrink-0 text-sm leading-8 text-[var(--el-text-color-secondary)] sm:w-36"
>
{{ $t('file.aiSearchLimitTotalHits') }}
</span>
<el-tooltip :content="$t('file.aiSearchMaxTotalHits')" placement="top">
<el-input-number
v-model="aiSearchMaxTotalHits"
:min="1"
:max="2000"
:controls="true"
class="min-w-0 flex-1 !max-w-full sm:!w-44"
>
<template #suffix>
<span class="pr-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('file.aiSearchLimitUnitLines') }}
</span>
</template>
</el-input-number>
</el-tooltip>
<el-input-number
v-model="aiSearchMaxTotalHits"
:min="1"
:max="2000"
:controls="true"
class="min-w-0 flex-1 !max-w-full sm:!w-52"
>
<template #suffix>
<span class="pr-1 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('file.aiSearchLimitUnitLines') }}
</span>
</template>
</el-input-number>
</div>
</div>
</div>
@@ -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() {
@@ -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);
}
</style>
@@ -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"
/>
<FileList ref="fileRef" @choose="getSearchPath" />
@@ -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();