mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-09 06:23:29 +08:00
✨ feat(query-editor): 支持 AI 行内补全与文本生成 SQL
- 新增 SQL 编辑器 AI 辅助服务,接入已配置供应商并生成行内补全 - 增加 Text-to-SQL 弹窗与插入、替换选区、替换全文应用模式 - 补齐多语言文案和定向测试覆盖
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import Editor, { type OnMount } from './MonacoEditor';
|
||||
import { message, Input, Form, MenuProps, Button } from 'antd';
|
||||
import { message, Input, Form, MenuProps, Button, Segmented } from 'antd';
|
||||
import { format } from 'sql-formatter';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { TabData, ColumnDefinition, type SqlSnippet } from '../types';
|
||||
@@ -142,6 +142,15 @@ import {
|
||||
splitQueryIdentifierPathSegments,
|
||||
stripCompletionIdentifierQuotes,
|
||||
} from './queryEditor/QueryEditorHelpers';
|
||||
import {
|
||||
getQueryEditorAiService,
|
||||
requestQueryEditorInlineCompletion,
|
||||
requestQueryEditorTextToSql,
|
||||
shouldRequestQueryEditorInlineCompletion,
|
||||
type QueryEditorAiApplyMode,
|
||||
type QueryEditorAiContext,
|
||||
type QueryEditorAiEditorSnapshot,
|
||||
} from './queryEditor/QueryEditorAiAssist';
|
||||
export {
|
||||
collectQueryEditorObjectDecorationCandidates,
|
||||
resolveQueryEditorNavigationDecorations,
|
||||
@@ -169,6 +178,13 @@ const buildQueryEditorMonacoOptions = (isObjectEditQueryTab: boolean) => ({
|
||||
scrollBeyondLastLine: false,
|
||||
quickSuggestions: { other: true, comments: false, strings: false },
|
||||
suggestOnTriggerCharacters: true,
|
||||
inlineSuggest: {
|
||||
enabled: true,
|
||||
mode: 'prefix' as const,
|
||||
showToolbar: 'onHover' as const,
|
||||
suppressSuggestions: false,
|
||||
minShowDelay: 160,
|
||||
},
|
||||
...(isObjectEditQueryTab
|
||||
? {
|
||||
fontSize: 14,
|
||||
@@ -731,7 +747,7 @@ const buildQueryEditorAiContextPrompt = (connection: any, database: string): str
|
||||
|
||||
// HMR 重载时释放旧注册避免补全和 hover 内容重复
|
||||
const _g = globalThis as any;
|
||||
const SQL_COMPLETION_PROVIDER_VERSION = '20260612-cursor-stable-completion-v1';
|
||||
const SQL_COMPLETION_PROVIDER_VERSION = '20260702-ai-inline-sql-v1';
|
||||
if (!_g.__gonaviSqlCompletionState) {
|
||||
_g.__gonaviSqlCompletionState = { registered: false, version: '', disposables: [] as any[] };
|
||||
}
|
||||
@@ -756,6 +772,8 @@ let sharedRoutinesData: CompletionRoutineMeta[] = [];
|
||||
let sharedSequencesData: CompletionSequenceMeta[] = [];
|
||||
let sharedPackagesData: CompletionPackageMeta[] = [];
|
||||
let sharedColumnsCacheData: Record<string, any[]> = {};
|
||||
let sharedActiveEditorModelUri = '';
|
||||
let sharedAiInlineCompletionRequestSeq = 0;
|
||||
const sharedLazyTablesCache: Record<string, CompletionTableMeta[] | undefined> = {};
|
||||
const sharedLazyTablesInFlight: Record<string, Promise<CompletionTableMeta[]> | undefined> = {};
|
||||
const createEmptySqlCompletionResult = () => ({ suggestions: [] as any[] });
|
||||
@@ -895,6 +913,7 @@ const resetSharedQueryEditorMetadata = () => {
|
||||
sharedSequencesData = [];
|
||||
sharedPackagesData = [];
|
||||
sharedColumnsCacheData = {};
|
||||
sharedActiveEditorModelUri = '';
|
||||
clearRecord(sharedLazyTablesCache);
|
||||
clearRecord(sharedLazyTablesInFlight);
|
||||
};
|
||||
@@ -933,6 +952,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const [currentConnectionId, setCurrentConnectionId] = useState<string>(tab.connectionId);
|
||||
const [currentDb, setCurrentDb] = useState<string>(tab.dbName || '');
|
||||
const [dbList, setDbList] = useState<string[]>([]);
|
||||
const [isTextToSqlModalOpen, setIsTextToSqlModalOpen] = useState(false);
|
||||
const [textToSqlInstruction, setTextToSqlInstruction] = useState('');
|
||||
const [textToSqlApplyMode, setTextToSqlApplyMode] = useState<QueryEditorAiApplyMode>('insert');
|
||||
const [textToSqlGenerating, setTextToSqlGenerating] = useState(false);
|
||||
|
||||
// Resizing state
|
||||
const [editorHeight, setEditorHeight] = useState(300);
|
||||
@@ -1412,6 +1435,46 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return query || '';
|
||||
}, [query]);
|
||||
|
||||
const buildQueryEditorAiEditorSnapshot = useCallback((): QueryEditorAiEditorSnapshot => {
|
||||
const editor = editorRef.current;
|
||||
const model = editor?.getModel?.();
|
||||
const position = normalizeEditorPosition(editor?.getPosition?.());
|
||||
const value = String(model?.getValue?.() ?? getCurrentQuery() ?? '');
|
||||
if (!model || !position || typeof model.getOffsetAt !== 'function') {
|
||||
return {
|
||||
prefix: value,
|
||||
suffix: '',
|
||||
currentLineBeforeCursor: value.split(/\r?\n/).pop() || '',
|
||||
currentLineAfterCursor: '',
|
||||
};
|
||||
}
|
||||
|
||||
const offset = Number(model.getOffsetAt(position));
|
||||
const safeOffset = Number.isFinite(offset)
|
||||
? Math.max(0, Math.min(offset, value.length))
|
||||
: value.length;
|
||||
const lineContent = String(model.getLineContent?.(position.lineNumber) || '');
|
||||
const lineColumnIndex = Math.max(0, Math.min(position.column - 1, lineContent.length));
|
||||
return {
|
||||
prefix: value.slice(0, safeOffset),
|
||||
suffix: value.slice(safeOffset),
|
||||
currentLineBeforeCursor: lineContent.slice(0, lineColumnIndex),
|
||||
currentLineAfterCursor: lineContent.slice(lineColumnIndex),
|
||||
};
|
||||
}, [getCurrentQuery]);
|
||||
|
||||
const buildQueryEditorAiContext = useCallback((): QueryEditorAiContext => {
|
||||
const conn = connectionsRef.current.find(c => c.id === currentConnectionIdRef.current);
|
||||
return {
|
||||
connectionName: conn?.name,
|
||||
sourceType: conn?.config?.type,
|
||||
currentDb: currentDbRef.current,
|
||||
visibleDbs: visibleDbsRef.current,
|
||||
tables: tablesRef.current,
|
||||
columns: allColumnsRef.current,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExternalSQLFileTab) return;
|
||||
persistQueryTabDraftSnapshot(draftSnapshotTab, getCurrentQuery(), {
|
||||
@@ -1443,6 +1506,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
sharedSequencesData = sequencesRef.current;
|
||||
sharedPackagesData = packagesRef.current;
|
||||
sharedColumnsCacheData = columnsCacheRef.current;
|
||||
sharedActiveEditorModelUri = String(editorRef.current?.getModel?.()?.uri?.toString?.() || '');
|
||||
}, [isActive, currentDb, currentConnectionId, connections]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1868,6 +1932,119 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
};
|
||||
|
||||
const openTextToSqlModal = useCallback(() => {
|
||||
const editor = editorRef.current;
|
||||
const selection = editor?.getSelection?.();
|
||||
const selectedText = selection ? String(editor?.getModel?.()?.getValueInRange?.(selection) || '') : '';
|
||||
setTextToSqlApplyMode(selectedText.trim() ? 'replaceSelection' : 'insert');
|
||||
setIsTextToSqlModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const applyTextToSqlResult = useCallback((sql: string, applyMode: QueryEditorAiApplyMode) => {
|
||||
const editor = editorRef.current;
|
||||
const monaco = monacoRef.current;
|
||||
const model = editor?.getModel?.();
|
||||
const nextSql = String(sql || '').trim();
|
||||
if (!nextSql) {
|
||||
return false;
|
||||
}
|
||||
if (!editor || !monaco?.Range || !model) {
|
||||
syncQueryToEditor(nextSql);
|
||||
refreshObjectDecorations();
|
||||
return true;
|
||||
}
|
||||
|
||||
const selection = editor.getSelection?.();
|
||||
const hasSelection = !!selection && !(typeof selection.isEmpty === 'function'
|
||||
? selection.isEmpty()
|
||||
: selection.startLineNumber === selection.endLineNumber && selection.startColumn === selection.endColumn);
|
||||
const lineCount = Number(model.getLineCount?.() || 1);
|
||||
const range = applyMode === 'replaceAll'
|
||||
? (
|
||||
model.getFullModelRange?.()
|
||||
|| new monaco.Range(1, 1, lineCount, Number(model.getLineMaxColumn?.(lineCount) || 1))
|
||||
)
|
||||
: applyMode === 'replaceSelection' && hasSelection
|
||||
? selection
|
||||
: (() => {
|
||||
const position = normalizeEditorPosition(editor.getPosition?.())
|
||||
|| normalizeEditorPosition(lastEditorCursorPositionRef.current)
|
||||
|| { lineNumber: lineCount, column: Number(model.getLineMaxColumn?.(lineCount) || 1) };
|
||||
return new monaco.Range(
|
||||
position.lineNumber,
|
||||
position.column,
|
||||
position.lineNumber,
|
||||
position.column,
|
||||
);
|
||||
})();
|
||||
|
||||
editor.focus?.();
|
||||
editor.pushUndoStop?.();
|
||||
editor.executeEdits?.('gonavi-text-to-sql', [{
|
||||
range,
|
||||
text: nextSql,
|
||||
forceMoveMarkers: true,
|
||||
}]);
|
||||
editor.pushUndoStop?.();
|
||||
const nextValue = String(editor.getValue?.() || nextSql);
|
||||
applyQueryState(nextValue);
|
||||
refreshObjectDecorations();
|
||||
return true;
|
||||
}, [applyQueryState, refreshObjectDecorations]);
|
||||
|
||||
const showTextToSqlReadinessWarning = useCallback((reason?: string) => {
|
||||
const key = reason === 'service_unavailable'
|
||||
? 'query_editor.message.ai_service_unavailable'
|
||||
: reason === 'model_missing'
|
||||
? 'query_editor.message.ai_model_missing'
|
||||
: 'query_editor.message.ai_provider_missing';
|
||||
void message.warning(translate(key));
|
||||
}, []);
|
||||
|
||||
const handleGenerateTextToSql = useCallback(async () => {
|
||||
const instruction = textToSqlInstruction.trim();
|
||||
if (!instruction) {
|
||||
void message.warning(translate('query_editor.message.text_to_sql_empty_instruction'));
|
||||
return;
|
||||
}
|
||||
|
||||
setTextToSqlGenerating(true);
|
||||
try {
|
||||
const { sql, readiness } = await requestQueryEditorTextToSql({
|
||||
service: getQueryEditorAiService(),
|
||||
aiContext: buildQueryEditorAiContext(),
|
||||
editorSnapshot: buildQueryEditorAiEditorSnapshot(),
|
||||
instruction,
|
||||
});
|
||||
if (!readiness.ready) {
|
||||
showTextToSqlReadinessWarning(readiness.reason);
|
||||
return;
|
||||
}
|
||||
if (!sql.trim()) {
|
||||
void message.warning(translate('query_editor.message.text_to_sql_empty_result'));
|
||||
return;
|
||||
}
|
||||
if (applyTextToSqlResult(sql, textToSqlApplyMode)) {
|
||||
setIsTextToSqlModalOpen(false);
|
||||
setTextToSqlInstruction('');
|
||||
void message.success(translate('query_editor.message.text_to_sql_success'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
void message.error(translate('query_editor.message.text_to_sql_failed', {
|
||||
error: error?.message || String(error || ''),
|
||||
}));
|
||||
} finally {
|
||||
setTextToSqlGenerating(false);
|
||||
}
|
||||
}, [
|
||||
applyTextToSqlResult,
|
||||
buildQueryEditorAiContext,
|
||||
buildQueryEditorAiEditorSnapshot,
|
||||
showTextToSqlReadinessWarning,
|
||||
textToSqlApplyMode,
|
||||
textToSqlInstruction,
|
||||
]);
|
||||
|
||||
// If opening a saved query, load its SQL
|
||||
useEffect(() => {
|
||||
const incoming = getTabQueryValue(tab);
|
||||
@@ -2692,8 +2869,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
};
|
||||
}
|
||||
lastEditorCursorPositionRef.current = normalizeEditorPosition(editor.getPosition?.());
|
||||
if (isActive) {
|
||||
sharedActiveEditorModelUri = String(editor.getModel?.()?.uri?.toString?.() || '');
|
||||
}
|
||||
|
||||
editor.updateOptions?.(buildQueryEditorMonacoOptions(isObjectEditQueryTab));
|
||||
if (monaco?.KeyCode?.RightArrow) {
|
||||
editor.addCommand?.(
|
||||
monaco.KeyCode.RightArrow,
|
||||
() => {
|
||||
void editor.getAction?.('editor.action.inlineSuggest.commit')?.run?.();
|
||||
},
|
||||
'inlineSuggestionVisible',
|
||||
);
|
||||
}
|
||||
|
||||
const applyNavigationHoverStateAtPosition = (targetPosition: { lineNumber: number; column: number } | null) => {
|
||||
if (!ctrlMetaPressedRef.current) {
|
||||
@@ -3074,6 +3263,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
setQueryEditorMouseCursor(editor, '');
|
||||
objectHoverActionRef.current?.dispose?.();
|
||||
objectHoverActionRef.current = null;
|
||||
const disposedModelUri = String(editor.getModel?.()?.uri?.toString?.() || '');
|
||||
if (disposedModelUri && sharedActiveEditorModelUri === disposedModelUri) {
|
||||
sharedActiveEditorModelUri = '';
|
||||
}
|
||||
disposeQueryEditorAiContextMenuActions();
|
||||
window.removeEventListener('keydown', syncModifierState);
|
||||
window.removeEventListener('keyup', syncModifierState);
|
||||
@@ -4033,6 +4226,87 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
},
|
||||
}));
|
||||
|
||||
if (typeof monaco.languages.registerInlineCompletionsProvider === 'function') {
|
||||
sqlCompletionDisposables.push(monaco.languages.registerInlineCompletionsProvider('sql', {
|
||||
groupId: 'gonavi-query-editor-ai',
|
||||
displayName: 'GoNavi AI SQL',
|
||||
debounceDelayMs: 650,
|
||||
provideInlineCompletions: async (model: any, position: any, context: any, token: any) => {
|
||||
if (context?.includeInlineCompletions === false || token?.isCancellationRequested) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const modelUri = String(model?.uri?.toString?.() || '');
|
||||
if (!modelUri || modelUri !== sharedActiveEditorModelUri) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const lineContent = String(model.getLineContent?.(position.lineNumber) || '');
|
||||
const lineColumnIndex = Math.max(0, Math.min(Number(position.column || 1) - 1, lineContent.length));
|
||||
const lineCount = Number(model.getLineCount?.() || position.lineNumber || 1);
|
||||
const prefixRange = new monaco.Range(1, 1, position.lineNumber, position.column);
|
||||
const suffixRange = new monaco.Range(
|
||||
position.lineNumber,
|
||||
position.column,
|
||||
lineCount,
|
||||
Number(model.getLineMaxColumn?.(lineCount) || position.column),
|
||||
);
|
||||
const editorSnapshot = {
|
||||
prefix: String(model.getValueInRange?.(prefixRange) || ''),
|
||||
suffix: String(model.getValueInRange?.(suffixRange) || ''),
|
||||
currentLineBeforeCursor: lineContent.slice(0, lineColumnIndex),
|
||||
currentLineAfterCursor: lineContent.slice(lineColumnIndex),
|
||||
};
|
||||
if (!shouldRequestQueryEditorInlineCompletion(editorSnapshot)) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const versionId = Number(model.getVersionId?.() || 0);
|
||||
const requestId = ++sharedAiInlineCompletionRequestSeq;
|
||||
try {
|
||||
const insertText = await requestQueryEditorInlineCompletion({
|
||||
service: getQueryEditorAiService(),
|
||||
aiContext: {
|
||||
connectionName: sharedConnections.find(c => c.id === sharedCurrentConnectionId)?.name,
|
||||
sourceType: sharedConnections.find(c => c.id === sharedCurrentConnectionId)?.config?.type,
|
||||
currentDb: sharedCurrentDb,
|
||||
visibleDbs: sharedVisibleDbs,
|
||||
tables: sharedTablesData,
|
||||
columns: sharedAllColumnsData,
|
||||
},
|
||||
editorSnapshot,
|
||||
});
|
||||
if (
|
||||
token?.isCancellationRequested
|
||||
|| requestId !== sharedAiInlineCompletionRequestSeq
|
||||
|| (versionId && Number(model.getVersionId?.() || 0) !== versionId)
|
||||
|| !insertText.trim()
|
||||
) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
items: [{
|
||||
insertText,
|
||||
range: new monaco.Range(
|
||||
position.lineNumber,
|
||||
position.column,
|
||||
position.lineNumber,
|
||||
position.column,
|
||||
),
|
||||
}],
|
||||
suppressSuggestions: false,
|
||||
enableForwardStability: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('GoNavi AI inline SQL completion failed', error);
|
||||
return { items: [] };
|
||||
}
|
||||
},
|
||||
disposeInlineCompletions: () => {},
|
||||
}));
|
||||
}
|
||||
|
||||
} // end sqlCompletionRegistered guard
|
||||
|
||||
// 每个编辑器实例都注册内容变化监听(检测斜杠命令标记)
|
||||
@@ -4164,6 +4438,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
};
|
||||
|
||||
const handleAIAction = (action: 'generate' | 'explain' | 'optimize' | 'schema') => {
|
||||
if (action === 'generate') {
|
||||
openTextToSqlModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = editorRef.current;
|
||||
const selection = editor?.getModel()?.getValueInRange(editor.getSelection()) || '';
|
||||
const fullSQL = getCurrentQuery();
|
||||
@@ -6379,6 +6658,77 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={translate('query_editor.text_to_sql.title')}
|
||||
open={isTextToSqlModalOpen}
|
||||
centered
|
||||
mask={false}
|
||||
maskClosable={!textToSqlGenerating}
|
||||
width={640}
|
||||
draggable
|
||||
resizable
|
||||
minResizableWidth={480}
|
||||
minResizableHeight={320}
|
||||
onCancel={() => {
|
||||
if (!textToSqlGenerating) {
|
||||
setIsTextToSqlModalOpen(false);
|
||||
}
|
||||
}}
|
||||
footer={[
|
||||
<Button key="cancel" disabled={textToSqlGenerating} onClick={() => setIsTextToSqlModalOpen(false)}>
|
||||
{translate('common.cancel')}
|
||||
</Button>,
|
||||
<Button key="generate" type="primary" loading={textToSqlGenerating} onClick={handleGenerateTextToSql}>
|
||||
{translate('query_editor.text_to_sql.generate')}
|
||||
</Button>,
|
||||
]}
|
||||
styles={{
|
||||
content: {
|
||||
borderRadius: 16,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(15,23,42,0.12)',
|
||||
background: darkMode ? 'rgba(18,18,20,0.98)' : 'rgba(255,255,255,0.98)',
|
||||
boxShadow: darkMode ? '0 24px 60px rgba(0,0,0,0.45)' : '0 24px 60px rgba(15,23,42,0.16)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
},
|
||||
header: {
|
||||
background: 'transparent',
|
||||
borderBottom: 'none',
|
||||
paddingBottom: 8,
|
||||
},
|
||||
body: {
|
||||
paddingTop: 8,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-query-editor-text-to-sql-modal="true"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 14 }}
|
||||
>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.6, color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(16,24,40,0.6)' }}>
|
||||
{translate('query_editor.text_to_sql.description')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
autoFocus
|
||||
value={textToSqlInstruction}
|
||||
onChange={(event) => setTextToSqlInstruction(event.target.value)}
|
||||
placeholder={translate('query_editor.text_to_sql.placeholder')}
|
||||
autoSize={{ minRows: 5, maxRows: 10 }}
|
||||
disabled={textToSqlGenerating}
|
||||
/>
|
||||
<Segmented
|
||||
value={textToSqlApplyMode}
|
||||
onChange={(value) => setTextToSqlApplyMode(value as QueryEditorAiApplyMode)}
|
||||
disabled={textToSqlGenerating}
|
||||
options={[
|
||||
{ label: translate('query_editor.text_to_sql.mode.insert'), value: 'insert' },
|
||||
{ label: translate('query_editor.text_to_sql.mode.replace_selection'), value: 'replaceSelection' },
|
||||
{ label: translate('query_editor.text_to_sql.mode.replace_all'), value: 'replaceAll' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={translate('query_editor.snippet_picker.title')}
|
||||
open={isSqlSnippetPickerOpen}
|
||||
|
||||
@@ -7,6 +7,7 @@ const legacyLiterals = [
|
||||
'隐藏结果区',
|
||||
'显示结果区',
|
||||
'生成 SQL',
|
||||
'Text-to-SQL',
|
||||
'解释 SQL',
|
||||
'优化 SQL',
|
||||
'Schema 分析',
|
||||
@@ -47,7 +48,7 @@ const requiredKeys = [
|
||||
'query_editor.action.format',
|
||||
'query_editor.action.format_sql',
|
||||
'query_editor.action.format_sql_with_shortcut',
|
||||
'query_editor.action.ai_generate_sql_menu',
|
||||
'query_editor.action.ai_text_to_sql_menu',
|
||||
'query_editor.action.ai_explain_sql_menu',
|
||||
'query_editor.action.ai_optimize_sql_menu',
|
||||
'query_editor.action.ai_schema_analysis',
|
||||
|
||||
@@ -178,7 +178,7 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
|
||||
const aiMenuItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "ai-generate",
|
||||
label: t("query_editor.action.ai_generate_sql_menu"),
|
||||
label: t("query_editor.action.ai_text_to_sql_menu"),
|
||||
icon: <RobotOutlined />,
|
||||
onClick: () => onAIAction("generate"),
|
||||
},
|
||||
|
||||
155
frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts
Normal file
155
frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildQueryEditorInlineCompletionMessages,
|
||||
buildQueryEditorTextToSqlMessages,
|
||||
requestQueryEditorInlineCompletion,
|
||||
resolveInlineSqlInsertText,
|
||||
resolveQueryEditorAiRuntimeReadiness,
|
||||
sanitizeSqlAssistantResponse,
|
||||
shouldRequestQueryEditorInlineCompletion,
|
||||
type QueryEditorAiService,
|
||||
} from './QueryEditorAiAssist';
|
||||
|
||||
const readyService = (content = 'SELECT * FROM users;'): QueryEditorAiService => ({
|
||||
AIGetProviders: vi.fn(async () => [{
|
||||
id: 'openai-main',
|
||||
type: 'openai' as const,
|
||||
name: 'OpenAI',
|
||||
apiKey: '',
|
||||
hasSecret: true,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-5',
|
||||
maxTokens: 2048,
|
||||
temperature: 0.2,
|
||||
}]),
|
||||
AIGetActiveProvider: vi.fn(async () => 'openai-main'),
|
||||
AIGetUserPromptSettings: vi.fn(async () => ({
|
||||
global: 'Keep answers deterministic.',
|
||||
database: 'Prefer readonly SQL.',
|
||||
})),
|
||||
AIChatSend: vi.fn(async () => ({ success: true, content })),
|
||||
});
|
||||
|
||||
describe('QueryEditorAiAssist', () => {
|
||||
it('only requests inline completion in editable SQL context', () => {
|
||||
expect(shouldRequestQueryEditorInlineCompletion({
|
||||
prefix: 'select',
|
||||
suffix: '',
|
||||
currentLineBeforeCursor: 'select',
|
||||
currentLineAfterCursor: '',
|
||||
})).toBe(true);
|
||||
|
||||
expect(shouldRequestQueryEditorInlineCompletion({
|
||||
prefix: '-- select',
|
||||
suffix: '',
|
||||
currentLineBeforeCursor: '-- select',
|
||||
currentLineAfterCursor: '',
|
||||
})).toBe(false);
|
||||
|
||||
expect(shouldRequestQueryEditorInlineCompletion({
|
||||
prefix: "select 'abc",
|
||||
suffix: '',
|
||||
currentLineBeforeCursor: "select 'abc",
|
||||
currentLineAfterCursor: '',
|
||||
})).toBe(false);
|
||||
|
||||
expect(shouldRequestQueryEditorInlineCompletion({
|
||||
prefix: 'select',
|
||||
suffix: ' from users',
|
||||
currentLineBeforeCursor: 'select',
|
||||
currentLineAfterCursor: ' from users',
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('sanitizes fenced SQL and removes duplicated typed prefixes', () => {
|
||||
expect(sanitizeSqlAssistantResponse('```sql\nselect * from users;\n```')).toBe('select * from users;');
|
||||
expect(sanitizeSqlAssistantResponse('SQL: select count(*) from orders;')).toBe('select count(*) from orders;');
|
||||
|
||||
expect(resolveInlineSqlInsertText('SELECT * FROM users;', 'select')).toBe(' * FROM users;');
|
||||
expect(resolveInlineSqlInsertText('from users;', 'select ')).toBe('from users;');
|
||||
expect(resolveInlineSqlInsertText('orders', 'select * from')).toBe(' orders');
|
||||
});
|
||||
|
||||
it('builds inline and text-to-sql prompts with custom instructions and schema hints', () => {
|
||||
const aiContext = {
|
||||
connectionName: 'Local MySQL',
|
||||
sourceType: 'mysql',
|
||||
currentDb: 'shop',
|
||||
visibleDbs: ['shop'],
|
||||
tables: [{ dbName: 'shop', tableName: 'orders', comment: 'sales orders' }],
|
||||
columns: [
|
||||
{ dbName: 'shop', tableName: 'orders', name: 'id', type: 'bigint' },
|
||||
{ dbName: 'shop', tableName: 'orders', name: 'amount', type: 'decimal' },
|
||||
],
|
||||
};
|
||||
const userPromptSettings = {
|
||||
global: 'Always use explicit column names.',
|
||||
database: 'Readonly by default.',
|
||||
jvm: '',
|
||||
jvmDiagnostic: '',
|
||||
};
|
||||
|
||||
const inlineMessages = buildQueryEditorInlineCompletionMessages({
|
||||
aiContext,
|
||||
editorSnapshot: {
|
||||
prefix: 'select',
|
||||
suffix: '',
|
||||
currentLineBeforeCursor: 'select',
|
||||
currentLineAfterCursor: '',
|
||||
},
|
||||
userPromptSettings,
|
||||
});
|
||||
const inlineJoined = inlineMessages.map((message) => message.content).join('\n');
|
||||
expect(inlineJoined).toContain('Always use explicit column names.');
|
||||
expect(inlineJoined).toContain('shop.orders -- sales orders; columns: id bigint, amount decimal');
|
||||
expect(inlineJoined).toContain('<prefix_before_cursor>');
|
||||
|
||||
const textToSqlMessages = buildQueryEditorTextToSqlMessages({
|
||||
aiContext,
|
||||
editorSnapshot: {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
currentLineBeforeCursor: '',
|
||||
currentLineAfterCursor: '',
|
||||
},
|
||||
instruction: 'total order amount by day',
|
||||
userPromptSettings,
|
||||
});
|
||||
expect(textToSqlMessages.map((message) => message.content).join('\n')).toContain('total order amount by day');
|
||||
});
|
||||
|
||||
it('checks active provider readiness before inline AI requests', async () => {
|
||||
const service = readyService('select * from users;');
|
||||
const readiness = await resolveQueryEditorAiRuntimeReadiness(service);
|
||||
expect(readiness.ready).toBe(true);
|
||||
expect(readiness.provider?.model).toBe('gpt-5');
|
||||
|
||||
const insertText = await requestQueryEditorInlineCompletion({
|
||||
service,
|
||||
aiContext: {
|
||||
connectionName: 'Local MySQL',
|
||||
sourceType: 'mysql',
|
||||
currentDb: 'shop',
|
||||
tables: [],
|
||||
columns: [],
|
||||
},
|
||||
editorSnapshot: {
|
||||
prefix: 'select',
|
||||
suffix: '',
|
||||
currentLineBeforeCursor: 'select',
|
||||
currentLineAfterCursor: '',
|
||||
},
|
||||
});
|
||||
expect(insertText).toBe(' * from users;');
|
||||
expect(service.AIChatSend).toHaveBeenCalledTimes(1);
|
||||
|
||||
const missingProvider = await resolveQueryEditorAiRuntimeReadiness({
|
||||
AIChatSend: vi.fn(),
|
||||
AIGetProviders: vi.fn(async () => []),
|
||||
AIGetActiveProvider: vi.fn(async () => ''),
|
||||
});
|
||||
expect(missingProvider.ready).toBe(false);
|
||||
expect(missingProvider.reason).toBe('provider_missing');
|
||||
});
|
||||
});
|
||||
496
frontend/src/components/queryEditor/QueryEditorAiAssist.ts
Normal file
496
frontend/src/components/queryEditor/QueryEditorAiAssist.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import type {
|
||||
AIProviderConfig,
|
||||
AIUserPromptSettings,
|
||||
} from '../../types';
|
||||
import type {
|
||||
CompletionColumnMeta,
|
||||
CompletionTableMeta,
|
||||
} from './QueryEditorHelpers';
|
||||
|
||||
export type QueryEditorAiApplyMode = 'insert' | 'replaceSelection' | 'replaceAll';
|
||||
|
||||
export interface QueryEditorAiService {
|
||||
AIGetProviders?: () => Promise<AIProviderConfig[]>;
|
||||
AIGetActiveProvider?: () => Promise<string>;
|
||||
AIGetUserPromptSettings?: () => Promise<Partial<AIUserPromptSettings>>;
|
||||
AIChatSend?: (messages: QueryEditorAiMessage[], tools?: any[]) => Promise<Record<string, any>>;
|
||||
}
|
||||
|
||||
export interface QueryEditorAiMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface QueryEditorAiContext {
|
||||
connectionName?: string;
|
||||
sourceType?: string;
|
||||
currentDb?: string;
|
||||
visibleDbs?: string[];
|
||||
tables?: CompletionTableMeta[];
|
||||
columns?: CompletionColumnMeta[];
|
||||
}
|
||||
|
||||
export interface QueryEditorAiEditorSnapshot {
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
currentLineBeforeCursor: string;
|
||||
currentLineAfterCursor: string;
|
||||
}
|
||||
|
||||
export interface QueryEditorAiRuntimeReadiness {
|
||||
ready: boolean;
|
||||
reason?: 'service_unavailable' | 'provider_missing' | 'model_missing';
|
||||
provider?: AIProviderConfig;
|
||||
userPromptSettings: AIUserPromptSettings;
|
||||
}
|
||||
|
||||
const EMPTY_USER_PROMPT_SETTINGS: AIUserPromptSettings = {
|
||||
global: '',
|
||||
database: '',
|
||||
jvm: '',
|
||||
jvmDiagnostic: '',
|
||||
};
|
||||
|
||||
const INLINE_PREFIX_LIMIT = 3600;
|
||||
const INLINE_SUFFIX_LIMIT = 1200;
|
||||
const TEXT_TO_SQL_PREFIX_LIMIT = 5000;
|
||||
const TEXT_TO_SQL_SUFFIX_LIMIT = 1800;
|
||||
const MAX_SCHEMA_SNAPSHOT_CHARS = 7000;
|
||||
const MAX_SCHEMA_TABLES = 48;
|
||||
const MAX_SCHEMA_COLUMNS_PER_TABLE = 14;
|
||||
const MAX_INLINE_INSERT_CHARS = 1800;
|
||||
|
||||
const SQL_CODE_FENCE_RE = /```(?:sql|mysql|postgresql|postgres|oracle|plsql|sqlite|sqlserver|mssql|tsql|clickhouse|duckdb|starrocks|tdengine)?\s*([\s\S]*?)```/i;
|
||||
|
||||
export const getQueryEditorAiService = (): QueryEditorAiService | undefined =>
|
||||
(window as any)?.go?.aiservice?.Service;
|
||||
|
||||
export const resolveQueryEditorAiRuntimeReadiness = async (
|
||||
service: QueryEditorAiService | undefined,
|
||||
): Promise<QueryEditorAiRuntimeReadiness> => {
|
||||
if (!service?.AIChatSend || !service?.AIGetProviders || !service?.AIGetActiveProvider) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: 'service_unavailable',
|
||||
userPromptSettings: EMPTY_USER_PROMPT_SETTINGS,
|
||||
};
|
||||
}
|
||||
|
||||
const [providers, activeProviderId, rawUserPromptSettings] = await Promise.all([
|
||||
service.AIGetProviders(),
|
||||
service.AIGetActiveProvider(),
|
||||
service.AIGetUserPromptSettings?.().catch(() => EMPTY_USER_PROMPT_SETTINGS),
|
||||
]);
|
||||
const provider = Array.isArray(providers)
|
||||
? providers.find((item) => item.id === activeProviderId)
|
||||
: undefined;
|
||||
const userPromptSettings = {
|
||||
...EMPTY_USER_PROMPT_SETTINGS,
|
||||
...(rawUserPromptSettings || {}),
|
||||
};
|
||||
|
||||
if (!provider) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: 'provider_missing',
|
||||
userPromptSettings,
|
||||
};
|
||||
}
|
||||
if (!String(provider.model || '').trim()) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: 'model_missing',
|
||||
provider,
|
||||
userPromptSettings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
provider,
|
||||
userPromptSettings,
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldRequestQueryEditorInlineCompletion = (
|
||||
snapshot: QueryEditorAiEditorSnapshot,
|
||||
): boolean => {
|
||||
const lineAfterCursor = String(snapshot.currentLineAfterCursor || '');
|
||||
if (lineAfterCursor.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prefix = String(snapshot.prefix || '');
|
||||
const currentStatement = getCurrentStatementPrefix(prefix);
|
||||
const trimmedStatement = currentStatement.trim();
|
||||
if (trimmedStatement.length < 3) {
|
||||
return false;
|
||||
}
|
||||
if (/[;)]\s*$/.test(trimmedStatement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentLine = String(snapshot.currentLineBeforeCursor || '');
|
||||
const trimmedLine = currentLine.trimStart();
|
||||
if (trimmedLine.startsWith('--') || trimmedLine.startsWith('#')) {
|
||||
return false;
|
||||
}
|
||||
if (currentLine.includes('--')) {
|
||||
return false;
|
||||
}
|
||||
if (hasUnclosedBlockComment(prefix) || hasUnclosedSqlString(currentStatement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const requestQueryEditorInlineCompletion = async ({
|
||||
service,
|
||||
aiContext,
|
||||
editorSnapshot,
|
||||
}: {
|
||||
service: QueryEditorAiService | undefined;
|
||||
aiContext: QueryEditorAiContext;
|
||||
editorSnapshot: QueryEditorAiEditorSnapshot;
|
||||
}): Promise<string> => {
|
||||
if (!shouldRequestQueryEditorInlineCompletion(editorSnapshot)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const readiness = await resolveQueryEditorAiRuntimeReadiness(service);
|
||||
if (!readiness.ready || !readiness.provider) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const messages = buildQueryEditorInlineCompletionMessages({
|
||||
aiContext,
|
||||
editorSnapshot,
|
||||
userPromptSettings: readiness.userPromptSettings,
|
||||
});
|
||||
const result = await service!.AIChatSend!(messages, []);
|
||||
if (!result?.success || !result.content) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sanitized = sanitizeSqlAssistantResponse(String(result.content || ''));
|
||||
const insertText = resolveInlineSqlInsertText(sanitized, editorSnapshot.prefix);
|
||||
return limitInlineInsertText(insertText);
|
||||
};
|
||||
|
||||
export const requestQueryEditorTextToSql = async ({
|
||||
service,
|
||||
aiContext,
|
||||
editorSnapshot,
|
||||
instruction,
|
||||
}: {
|
||||
service: QueryEditorAiService | undefined;
|
||||
aiContext: QueryEditorAiContext;
|
||||
editorSnapshot: QueryEditorAiEditorSnapshot;
|
||||
instruction: string;
|
||||
}): Promise<{ sql: string; readiness: QueryEditorAiRuntimeReadiness }> => {
|
||||
const readiness = await resolveQueryEditorAiRuntimeReadiness(service);
|
||||
if (!readiness.ready) {
|
||||
return { sql: '', readiness };
|
||||
}
|
||||
|
||||
const messages = buildQueryEditorTextToSqlMessages({
|
||||
aiContext,
|
||||
editorSnapshot,
|
||||
instruction,
|
||||
userPromptSettings: readiness.userPromptSettings,
|
||||
});
|
||||
const result = await service!.AIChatSend!(messages, []);
|
||||
if (!result?.success) {
|
||||
throw new Error(String(result?.error || 'AI request failed'));
|
||||
}
|
||||
|
||||
return {
|
||||
sql: sanitizeSqlAssistantResponse(String(result.content || '')),
|
||||
readiness,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildQueryEditorInlineCompletionMessages = ({
|
||||
aiContext,
|
||||
editorSnapshot,
|
||||
userPromptSettings,
|
||||
}: {
|
||||
aiContext: QueryEditorAiContext;
|
||||
editorSnapshot: QueryEditorAiEditorSnapshot;
|
||||
userPromptSettings: AIUserPromptSettings;
|
||||
}): QueryEditorAiMessage[] => [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
'You are GoNavi SQL inline completion.',
|
||||
'Return only the exact SQL text that should be inserted at the cursor.',
|
||||
'Do not use Markdown, code fences, explanations, comments about your answer, or natural language.',
|
||||
'Continue the current SQL instead of repeating text that already exists before the cursor.',
|
||||
'Respect the database dialect, current database, and schema hints. Prefer concise, executable SQL.',
|
||||
].join('\n'),
|
||||
},
|
||||
...buildCustomPromptMessages(userPromptSettings),
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
buildQueryEditorAiContextBlock(aiContext),
|
||||
'Editor snapshot:',
|
||||
'<prefix_before_cursor>',
|
||||
truncateHead(editorSnapshot.prefix, INLINE_PREFIX_LIMIT),
|
||||
'</prefix_before_cursor>',
|
||||
'<suffix_after_cursor>',
|
||||
truncateTail(editorSnapshot.suffix, INLINE_SUFFIX_LIMIT),
|
||||
'</suffix_after_cursor>',
|
||||
'The cursor is at the end of prefix_before_cursor. Generate the continuation text only.',
|
||||
].join('\n'),
|
||||
},
|
||||
];
|
||||
|
||||
export const buildQueryEditorTextToSqlMessages = ({
|
||||
aiContext,
|
||||
editorSnapshot,
|
||||
instruction,
|
||||
userPromptSettings,
|
||||
}: {
|
||||
aiContext: QueryEditorAiContext;
|
||||
editorSnapshot: QueryEditorAiEditorSnapshot;
|
||||
instruction: string;
|
||||
userPromptSettings: AIUserPromptSettings;
|
||||
}): QueryEditorAiMessage[] => [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
'You are GoNavi Text-to-SQL.',
|
||||
'Generate SQL for the SQL editor from the user request.',
|
||||
'Return only SQL. Do not use Markdown, code fences, or explanations.',
|
||||
'Respect the database dialect, current database, schema hints, and existing editor context.',
|
||||
'Prefer read-only SQL unless the user explicitly asks for data or schema changes.',
|
||||
].join('\n'),
|
||||
},
|
||||
...buildCustomPromptMessages(userPromptSettings),
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
buildQueryEditorAiContextBlock(aiContext),
|
||||
'User request:',
|
||||
instruction.trim(),
|
||||
'',
|
||||
'Current editor context:',
|
||||
'<prefix_before_cursor>',
|
||||
truncateHead(editorSnapshot.prefix, TEXT_TO_SQL_PREFIX_LIMIT),
|
||||
'</prefix_before_cursor>',
|
||||
'<suffix_after_cursor>',
|
||||
truncateTail(editorSnapshot.suffix, TEXT_TO_SQL_SUFFIX_LIMIT),
|
||||
'</suffix_after_cursor>',
|
||||
].join('\n'),
|
||||
},
|
||||
];
|
||||
|
||||
export const sanitizeSqlAssistantResponse = (raw: string): string => {
|
||||
let text = String(raw || '').trim();
|
||||
const fenceMatch = text.match(SQL_CODE_FENCE_RE);
|
||||
if (fenceMatch?.[1]) {
|
||||
text = fenceMatch[1].trim();
|
||||
}
|
||||
text = text
|
||||
.replace(/^\s*(?:sql|query|answer)\s*[::]\s*/i, '')
|
||||
.replace(/^\s*Here is (?:the )?SQL\s*[::]\s*/i, '')
|
||||
.trim();
|
||||
if (
|
||||
text.length >= 2
|
||||
&& ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))
|
||||
&& !text.includes('\n')
|
||||
) {
|
||||
text = text.slice(1, -1).trim();
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
export const resolveInlineSqlInsertText = (generatedSql: string, prefix: string): string => {
|
||||
const generated = String(generatedSql || '').trimEnd();
|
||||
if (!generated.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const prefixText = String(prefix || '');
|
||||
const statementPrefix = getCurrentStatementPrefix(prefixText);
|
||||
const candidates = [
|
||||
prefixText.slice(-INLINE_PREFIX_LIMIT),
|
||||
statementPrefix,
|
||||
statementPrefix.trimStart(),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const overlap = findCaseInsensitiveOverlap(candidate, generated);
|
||||
if (overlap > 0) {
|
||||
return generated.slice(overlap);
|
||||
}
|
||||
}
|
||||
|
||||
if (/\w$/.test(prefixText) && /^\w/.test(generated)) {
|
||||
return ` ${generated}`;
|
||||
}
|
||||
return generated;
|
||||
};
|
||||
|
||||
export const buildQueryEditorAiContextBlock = (context: QueryEditorAiContext): string => {
|
||||
const sourceType = String(context.sourceType || '').trim() || 'unknown';
|
||||
const connectionName = String(context.connectionName || '').trim() || 'unknown';
|
||||
const currentDb = String(context.currentDb || '').trim() || 'default';
|
||||
const visibleDbs = (context.visibleDbs || [])
|
||||
.map((db) => String(db || '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 24)
|
||||
.join(', ');
|
||||
|
||||
return [
|
||||
'Database context:',
|
||||
`- source_type: ${sourceType}`,
|
||||
`- connection: ${connectionName}`,
|
||||
`- current_database: ${currentDb}`,
|
||||
visibleDbs ? `- visible_databases: ${visibleDbs}` : '',
|
||||
'Schema hints:',
|
||||
buildSchemaSnapshot(context),
|
||||
].filter(Boolean).join('\n');
|
||||
};
|
||||
|
||||
const buildCustomPromptMessages = (settings: AIUserPromptSettings): QueryEditorAiMessage[] => {
|
||||
const prompts = [
|
||||
String(settings.global || '').trim(),
|
||||
String(settings.database || '').trim(),
|
||||
].filter(Boolean);
|
||||
if (!prompts.length) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
role: 'system',
|
||||
content: [
|
||||
'User configured GoNavi AI instructions:',
|
||||
...prompts.map((prompt, index) => `Instruction ${index + 1}:\n${prompt}`),
|
||||
].join('\n\n'),
|
||||
}];
|
||||
};
|
||||
|
||||
const buildSchemaSnapshot = (context: QueryEditorAiContext): string => {
|
||||
const currentDb = String(context.currentDb || '').trim().toLowerCase();
|
||||
const columnsByTable = new Map<string, CompletionColumnMeta[]>();
|
||||
(context.columns || []).forEach((column) => {
|
||||
const key = schemaItemKey(column.dbName, column.tableName);
|
||||
const existing = columnsByTable.get(key) || [];
|
||||
if (existing.length < MAX_SCHEMA_COLUMNS_PER_TABLE) {
|
||||
existing.push(column);
|
||||
columnsByTable.set(key, existing);
|
||||
}
|
||||
});
|
||||
|
||||
const tables = [...(context.tables || [])]
|
||||
.filter((table) => String(table.tableName || '').trim())
|
||||
.sort((left, right) => {
|
||||
const leftDb = String(left.dbName || '').trim().toLowerCase();
|
||||
const rightDb = String(right.dbName || '').trim().toLowerCase();
|
||||
if (leftDb === currentDb && rightDb !== currentDb) return -1;
|
||||
if (rightDb === currentDb && leftDb !== currentDb) return 1;
|
||||
return String(left.tableName || '').localeCompare(String(right.tableName || ''));
|
||||
})
|
||||
.slice(0, MAX_SCHEMA_TABLES);
|
||||
|
||||
if (!tables.length) {
|
||||
return '- No table metadata is loaded yet. Use the current SQL and database name as context.';
|
||||
}
|
||||
|
||||
const lines = tables.map((table) => {
|
||||
const dbName = String(table.dbName || context.currentDb || '').trim();
|
||||
const tableName = String(table.tableName || '').trim();
|
||||
const columns = columnsByTable.get(schemaItemKey(dbName, tableName)) || [];
|
||||
const columnText = columns.length
|
||||
? columns.map((column) => {
|
||||
const type = String(column.type || '').trim();
|
||||
const comment = String(column.comment || '').trim();
|
||||
return [
|
||||
String(column.name || '').trim(),
|
||||
type ? ` ${type}` : '',
|
||||
comment ? ` -- ${comment}` : '',
|
||||
].join('');
|
||||
}).join(', ')
|
||||
: 'columns unavailable';
|
||||
const comment = String(table.comment || '').trim();
|
||||
return `- ${dbName ? `${dbName}.` : ''}${tableName}${comment ? ` -- ${comment}` : ''}; columns: ${columnText}`;
|
||||
});
|
||||
|
||||
const snapshot = lines.join('\n');
|
||||
return snapshot.length > MAX_SCHEMA_SNAPSHOT_CHARS
|
||||
? `${snapshot.slice(0, MAX_SCHEMA_SNAPSHOT_CHARS)}\n- ...schema snapshot truncated`
|
||||
: snapshot;
|
||||
};
|
||||
|
||||
const schemaItemKey = (dbName: string, tableName: string): string =>
|
||||
`${String(dbName || '').trim().toLowerCase()}\u0000${String(tableName || '').trim().toLowerCase()}`;
|
||||
|
||||
const getCurrentStatementPrefix = (prefix: string): string => {
|
||||
const text = String(prefix || '');
|
||||
const semicolonIndex = text.lastIndexOf(';');
|
||||
return semicolonIndex >= 0 ? text.slice(semicolonIndex + 1) : text;
|
||||
};
|
||||
|
||||
const hasUnclosedBlockComment = (text: string): boolean =>
|
||||
String(text || '').lastIndexOf('/*') > String(text || '').lastIndexOf('*/');
|
||||
|
||||
const hasUnclosedSqlString = (text: string): boolean => {
|
||||
let singleOpen = false;
|
||||
let doubleOpen = false;
|
||||
let backtickOpen = false;
|
||||
const value = String(text || '');
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const char = value[index];
|
||||
const next = value[index + 1];
|
||||
if (char === "'" && !doubleOpen && !backtickOpen) {
|
||||
if (next === "'") {
|
||||
index += 1;
|
||||
} else {
|
||||
singleOpen = !singleOpen;
|
||||
}
|
||||
} else if (char === '"' && !singleOpen && !backtickOpen) {
|
||||
doubleOpen = !doubleOpen;
|
||||
} else if (char === '`' && !singleOpen && !doubleOpen) {
|
||||
backtickOpen = !backtickOpen;
|
||||
}
|
||||
}
|
||||
return singleOpen || doubleOpen || backtickOpen;
|
||||
};
|
||||
|
||||
const findCaseInsensitiveOverlap = (prefix: string, completion: string): number => {
|
||||
const left = String(prefix || '');
|
||||
const right = String(completion || '');
|
||||
const max = Math.min(left.length, right.length);
|
||||
const leftLower = left.toLowerCase();
|
||||
const rightLower = right.toLowerCase();
|
||||
for (let length = max; length > 0; length -= 1) {
|
||||
if (leftLower.slice(left.length - length) === rightLower.slice(0, length)) {
|
||||
return length;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const truncateHead = (text: string, limit: number): string => {
|
||||
const value = String(text || '');
|
||||
if (value.length <= limit) return value;
|
||||
return value.slice(value.length - limit);
|
||||
};
|
||||
|
||||
const truncateTail = (text: string, limit: number): string => {
|
||||
const value = String(text || '');
|
||||
if (value.length <= limit) return value;
|
||||
return value.slice(0, limit);
|
||||
};
|
||||
|
||||
const limitInlineInsertText = (text: string): string => {
|
||||
const value = String(text || '').trimEnd();
|
||||
if (value.length <= MAX_INLINE_INSERT_CHARS) {
|
||||
return value;
|
||||
}
|
||||
const truncated = value.slice(0, MAX_INLINE_INSERT_CHARS);
|
||||
const lastStatementEnd = Math.max(truncated.lastIndexOf(';'), truncated.lastIndexOf('\n'));
|
||||
return (lastStatementEnd > 80 ? truncated.slice(0, lastStatementEnd + 1) : truncated).trimEnd();
|
||||
};
|
||||
@@ -2244,8 +2244,8 @@ describe("i18n catalog", () => {
|
||||
),
|
||||
sliceBetween(
|
||||
source,
|
||||
" useEffect(() => {\n if (insertSqlSnippetActionRef.current) {",
|
||||
" useEffect(() => {\n if (runQueryActionRef.current) {",
|
||||
" const registerInsertSqlSnippetContextMenuAction = useCallback((editor: any) => {",
|
||||
" // SQL 诊断 / 慢 SQL 历史的快捷键监听(必须在 binding 声明之后)",
|
||||
),
|
||||
sliceBetween(
|
||||
source,
|
||||
|
||||
@@ -5954,6 +5954,7 @@
|
||||
"query_editor.action.ai_optimize_sql": "SQL mit AI optimieren",
|
||||
"query_editor.action.ai_optimize_sql_menu": "SQL optimieren",
|
||||
"query_editor.action.ai_schema_analysis": "Schema-Analyse",
|
||||
"query_editor.action.ai_text_to_sql_menu": "Text-to-SQL",
|
||||
"query_editor.action.export_sql_file": "SQL-Datei exportieren",
|
||||
"query_editor.action.format": "Formatieren",
|
||||
"query_editor.action.find_in_editor": "Suchen",
|
||||
@@ -6177,6 +6178,9 @@
|
||||
"query_editor.message.format_failed": "Formatierung fehlgeschlagen: Die SQL-Syntax ist möglicherweise ungültig.",
|
||||
"query_editor.message.format_restore_success": "Der SQL-Stand vor der Formatierung wurde wiederhergestellt.",
|
||||
"query_editor.message.insert_success": "Code an der aktuellen Cursorposition eingefügt.",
|
||||
"query_editor.message.ai_model_missing": "Für den aktiven AI Provider ist kein Modell ausgewählt. Wählen Sie zuerst in den AI-Einstellungen ein Modell aus.",
|
||||
"query_editor.message.ai_provider_missing": "Konfigurieren und aktivieren Sie zuerst einen AI Provider in den AI-Einstellungen.",
|
||||
"query_editor.message.ai_service_unavailable": "Der AI-Dienst ist noch nicht bereit. Versuchen Sie es später erneut oder prüfen Sie die AI-Einstellungen.",
|
||||
"query_editor.message.no_executable_sql": "Kein ausführbares SQL.",
|
||||
"query_editor.message.no_format_restore_snapshot": "Es ist kein SQL-Stand vor der Formatierung zum Wiederherstellen verfügbar.",
|
||||
"query_editor.message.current_line_no_copyable_content": "Die aktuelle Zeile enthält keinen kopierbaren Inhalt.",
|
||||
@@ -6199,6 +6203,10 @@
|
||||
"query_editor.message.select_database_first": "Wählen Sie zuerst eine Datenbank aus.",
|
||||
"query_editor.message.sql_file_saved": "SQL-Datei gespeichert.",
|
||||
"query_editor.message.statement_failed_prefix": "Anweisung {{index}} fehlgeschlagen: ",
|
||||
"query_editor.message.text_to_sql_empty_instruction": "Geben Sie die SQL-Anforderung ein, die erzeugt werden soll.",
|
||||
"query_editor.message.text_to_sql_empty_result": "AI hat kein einfügbares SQL zurückgegeben.",
|
||||
"query_editor.message.text_to_sql_failed": "Text-to-SQL-Erzeugung fehlgeschlagen: {{error}}",
|
||||
"query_editor.message.text_to_sql_success": "SQL wurde erzeugt und in den Editor geschrieben.",
|
||||
"query_editor.message.unsupported_source": "Diese Datenquelle unterstützt den SQL-Abfrageeditor nicht. Verwenden Sie stattdessen die zugehörige Seite.",
|
||||
"query_editor.object_info.column": "Spalte",
|
||||
"query_editor.object_info.database": "Datenbank",
|
||||
@@ -6270,6 +6278,13 @@
|
||||
"query_editor.slash_command.sql.description": "Anforderung beschreiben und Anweisung erzeugen",
|
||||
"query_editor.slash_command.sql.label": "SQL erzeugen",
|
||||
"query_editor.slash_command.sql.prompt": "Erzeuge SQL für diese Anforderung:",
|
||||
"query_editor.text_to_sql.description": "Beschreiben Sie die Daten, die Sie abfragen oder ändern möchten. GoNavi nutzt die aktuelle Verbindung, Datenbank und geladene Schema-Informationen zum Erzeugen von SQL.",
|
||||
"query_editor.text_to_sql.generate": "SQL erzeugen",
|
||||
"query_editor.text_to_sql.mode.insert": "Am Cursor einfügen",
|
||||
"query_editor.text_to_sql.mode.replace_all": "Alles ersetzen",
|
||||
"query_editor.text_to_sql.mode.replace_selection": "Auswahl ersetzen",
|
||||
"query_editor.text_to_sql.placeholder": "Beispiel: tägliche Bestellsumme der letzten 30 Tage zusammenfassen und nach Datum aufsteigend sortieren",
|
||||
"query_editor.text_to_sql.title": "Text-to-SQL",
|
||||
"query_editor.sql_error.rule.column_missing.explanation": "Das SQL verweist auf eine Spalte, die nicht im Ergebnissatz enthalten ist, anders geschrieben wurde oder in der aktuellen Tabelle nicht existiert.",
|
||||
"query_editor.sql_error.rule.column_missing.label": "Spalte existiert nicht",
|
||||
"query_editor.sql_error.rule.column_missing.suggestion": "Prüfe Spaltennamen, Aliasse, Groß-/Kleinschreibung, Tabellenaliase und ob die Spalte zum aktuellen FROM/JOIN-Objekt gehört.",
|
||||
|
||||
@@ -5954,6 +5954,7 @@
|
||||
"query_editor.action.ai_optimize_sql": "AI Optimize SQL",
|
||||
"query_editor.action.ai_optimize_sql_menu": "Optimize SQL",
|
||||
"query_editor.action.ai_schema_analysis": "Schema analysis",
|
||||
"query_editor.action.ai_text_to_sql_menu": "Text-to-SQL",
|
||||
"query_editor.action.export_sql_file": "Export SQL file",
|
||||
"query_editor.action.format": "Format",
|
||||
"query_editor.action.find_in_editor": "Find",
|
||||
@@ -6177,6 +6178,9 @@
|
||||
"query_editor.message.format_failed": "Format failed: SQL syntax may be invalid.",
|
||||
"query_editor.message.format_restore_success": "Restored the pre-format SQL snapshot.",
|
||||
"query_editor.message.insert_success": "Code inserted at the current cursor.",
|
||||
"query_editor.message.ai_model_missing": "The active AI provider has no model selected. Select a model in AI settings first.",
|
||||
"query_editor.message.ai_provider_missing": "Configure and enable an AI provider in AI settings first.",
|
||||
"query_editor.message.ai_service_unavailable": "The AI service is not ready. Try again later or check AI settings.",
|
||||
"query_editor.message.no_executable_sql": "No executable SQL.",
|
||||
"query_editor.message.no_format_restore_snapshot": "No pre-format SQL snapshot is available to restore.",
|
||||
"query_editor.message.current_line_no_copyable_content": "No copyable content on the current line.",
|
||||
@@ -6199,6 +6203,10 @@
|
||||
"query_editor.message.select_database_first": "Select a database first.",
|
||||
"query_editor.message.sql_file_saved": "SQL file saved.",
|
||||
"query_editor.message.statement_failed_prefix": "Statement {{index}} failed: ",
|
||||
"query_editor.message.text_to_sql_empty_instruction": "Enter the SQL requirement to generate.",
|
||||
"query_editor.message.text_to_sql_empty_result": "AI did not return insertable SQL.",
|
||||
"query_editor.message.text_to_sql_failed": "Text-to-SQL generation failed: {{error}}",
|
||||
"query_editor.message.text_to_sql_success": "SQL generated and written to the editor.",
|
||||
"query_editor.message.unsupported_source": "This data source does not support the SQL query editor. Use its dedicated page instead.",
|
||||
"query_editor.object_info.column": "Column",
|
||||
"query_editor.object_info.database": "Database",
|
||||
@@ -6270,6 +6278,13 @@
|
||||
"query_editor.slash_command.sql.description": "Describe the requirement and generate a statement",
|
||||
"query_editor.slash_command.sql.label": "Generate SQL",
|
||||
"query_editor.slash_command.sql.prompt": "Generate SQL for this requirement:",
|
||||
"query_editor.text_to_sql.description": "Describe the data you want to query or change. GoNavi will use the current connection, database, and loaded schema to generate SQL.",
|
||||
"query_editor.text_to_sql.generate": "Generate SQL",
|
||||
"query_editor.text_to_sql.mode.insert": "Insert at cursor",
|
||||
"query_editor.text_to_sql.mode.replace_all": "Replace all",
|
||||
"query_editor.text_to_sql.mode.replace_selection": "Replace selection",
|
||||
"query_editor.text_to_sql.placeholder": "Example: summarize daily order amount for the last 30 days and sort by date ascending",
|
||||
"query_editor.text_to_sql.title": "Text-to-SQL",
|
||||
"query_editor.sql_error.rule.column_missing.explanation": "The SQL references a column that is not in the result set, is spelled differently, or does not exist on the current table.",
|
||||
"query_editor.sql_error.rule.column_missing.label": "Column does not exist",
|
||||
"query_editor.sql_error.rule.column_missing.suggestion": "Check column names, aliases, casing, table aliases, and whether the column belongs to the current FROM/JOIN object.",
|
||||
|
||||
@@ -5954,6 +5954,7 @@
|
||||
"query_editor.action.ai_optimize_sql": "AI で SQL を最適化",
|
||||
"query_editor.action.ai_optimize_sql_menu": "SQL を最適化",
|
||||
"query_editor.action.ai_schema_analysis": "スキーマ分析",
|
||||
"query_editor.action.ai_text_to_sql_menu": "Text-to-SQL",
|
||||
"query_editor.action.export_sql_file": "SQL ファイルをエクスポート",
|
||||
"query_editor.action.format": "整形",
|
||||
"query_editor.action.find_in_editor": "検索",
|
||||
@@ -6177,6 +6178,9 @@
|
||||
"query_editor.message.format_failed": "整形に失敗しました: SQL 構文が正しくない可能性があります。",
|
||||
"query_editor.message.format_restore_success": "整形前の SQL に戻しました。",
|
||||
"query_editor.message.insert_success": "現在のカーソル位置にコードを挿入しました。",
|
||||
"query_editor.message.ai_model_missing": "現在の AI Provider でモデルが選択されていません。先に AI 設定でモデルを選択してください。",
|
||||
"query_editor.message.ai_provider_missing": "先に AI 設定で AI Provider を設定して有効化してください。",
|
||||
"query_editor.message.ai_service_unavailable": "AI サービスの準備ができていません。後でもう一度試すか、AI 設定を確認してください。",
|
||||
"query_editor.message.no_executable_sql": "実行できる SQL がありません。",
|
||||
"query_editor.message.no_format_restore_snapshot": "元に戻せる整形前の SQL はありません。",
|
||||
"query_editor.message.current_line_no_copyable_content": "現在の行にコピーできる内容がありません。",
|
||||
@@ -6199,6 +6203,10 @@
|
||||
"query_editor.message.select_database_first": "先にデータベースを選択してください。",
|
||||
"query_editor.message.sql_file_saved": "SQL ファイルを保存しました。",
|
||||
"query_editor.message.statement_failed_prefix": "{{index}} 番目のステートメントが失敗しました: ",
|
||||
"query_editor.message.text_to_sql_empty_instruction": "生成する SQL の要件を入力してください。",
|
||||
"query_editor.message.text_to_sql_empty_result": "AI から挿入可能な SQL が返されませんでした。",
|
||||
"query_editor.message.text_to_sql_failed": "Text-to-SQL の生成に失敗しました: {{error}}",
|
||||
"query_editor.message.text_to_sql_success": "SQL を生成してエディターに書き込みました。",
|
||||
"query_editor.message.unsupported_source": "このデータソースは SQL クエリエディターに対応していません。専用ページを使用してください。",
|
||||
"query_editor.object_info.column": "カラム",
|
||||
"query_editor.object_info.database": "データベース",
|
||||
@@ -6270,6 +6278,13 @@
|
||||
"query_editor.slash_command.sql.description": "要件を説明してステートメントを生成",
|
||||
"query_editor.slash_command.sql.label": "SQL を生成",
|
||||
"query_editor.slash_command.sql.prompt": "次の要件に対する SQL を生成してください:",
|
||||
"query_editor.text_to_sql.description": "クエリまたは処理したいデータを説明してください。GoNavi は現在の接続、データベース、読み込み済みスキーマを使って SQL を生成します。",
|
||||
"query_editor.text_to_sql.generate": "SQL を生成",
|
||||
"query_editor.text_to_sql.mode.insert": "カーソル位置に挿入",
|
||||
"query_editor.text_to_sql.mode.replace_all": "全文を置換",
|
||||
"query_editor.text_to_sql.mode.replace_selection": "選択範囲を置換",
|
||||
"query_editor.text_to_sql.placeholder": "例: 直近 30 日間の日別注文金額を集計し、日付の昇順で返す",
|
||||
"query_editor.text_to_sql.title": "Text-to-SQL",
|
||||
"query_editor.sql_error.rule.column_missing.explanation": "SQL が、結果セットに存在しない、スペルが異なる、または現在の表にない列を参照しています。",
|
||||
"query_editor.sql_error.rule.column_missing.label": "列が存在しません",
|
||||
"query_editor.sql_error.rule.column_missing.suggestion": "列名、別名、大文字小文字、表別名、および列が現在の FROM/JOIN オブジェクトに属しているか確認してください。",
|
||||
|
||||
@@ -5954,6 +5954,7 @@
|
||||
"query_editor.action.ai_optimize_sql": "AI: оптимизировать SQL",
|
||||
"query_editor.action.ai_optimize_sql_menu": "Оптимизировать SQL",
|
||||
"query_editor.action.ai_schema_analysis": "Анализ схемы",
|
||||
"query_editor.action.ai_text_to_sql_menu": "Text-to-SQL",
|
||||
"query_editor.action.export_sql_file": "Экспортировать SQL-файл",
|
||||
"query_editor.action.format": "Форматировать",
|
||||
"query_editor.action.find_in_editor": "Поиск",
|
||||
@@ -6177,6 +6178,9 @@
|
||||
"query_editor.message.format_failed": "Ошибка форматирования: возможно, синтаксис SQL неверен.",
|
||||
"query_editor.message.format_restore_success": "Восстановлено состояние SQL до форматирования.",
|
||||
"query_editor.message.insert_success": "Код вставлен в текущую позицию курсора.",
|
||||
"query_editor.message.ai_model_missing": "Для активного AI Provider не выбрана модель. Сначала выберите модель в настройках AI.",
|
||||
"query_editor.message.ai_provider_missing": "Сначала настройте и включите AI Provider в настройках AI.",
|
||||
"query_editor.message.ai_service_unavailable": "AI-сервис ещё не готов. Повторите попытку позже или проверьте настройки AI.",
|
||||
"query_editor.message.no_executable_sql": "Нет SQL для выполнения.",
|
||||
"query_editor.message.no_format_restore_snapshot": "Нет сохранённого состояния SQL до форматирования для восстановления.",
|
||||
"query_editor.message.current_line_no_copyable_content": "В текущей строке нет содержимого для копирования.",
|
||||
@@ -6199,6 +6203,10 @@
|
||||
"query_editor.message.select_database_first": "Сначала выберите базу данных.",
|
||||
"query_editor.message.sql_file_saved": "SQL-файл сохранен.",
|
||||
"query_editor.message.statement_failed_prefix": "Ошибка в инструкции {{index}}: ",
|
||||
"query_editor.message.text_to_sql_empty_instruction": "Введите требование для генерации SQL.",
|
||||
"query_editor.message.text_to_sql_empty_result": "AI не вернул SQL, который можно вставить.",
|
||||
"query_editor.message.text_to_sql_failed": "Не удалось сгенерировать Text-to-SQL: {{error}}",
|
||||
"query_editor.message.text_to_sql_success": "SQL сгенерирован и записан в редактор.",
|
||||
"query_editor.message.unsupported_source": "Этот источник данных не поддерживает редактор SQL-запросов. Используйте соответствующую страницу.",
|
||||
"query_editor.object_info.column": "Столбец",
|
||||
"query_editor.object_info.database": "База данных",
|
||||
@@ -6270,6 +6278,13 @@
|
||||
"query_editor.slash_command.sql.description": "Описать требование и сгенерировать инструкцию",
|
||||
"query_editor.slash_command.sql.label": "Сгенерировать SQL",
|
||||
"query_editor.slash_command.sql.prompt": "Сгенерируй SQL для этого требования:",
|
||||
"query_editor.text_to_sql.description": "Опишите данные, которые нужно запросить или изменить. GoNavi использует текущее подключение, базу данных и загруженную схему для генерации SQL.",
|
||||
"query_editor.text_to_sql.generate": "Сгенерировать SQL",
|
||||
"query_editor.text_to_sql.mode.insert": "Вставить в курсор",
|
||||
"query_editor.text_to_sql.mode.replace_all": "Заменить всё",
|
||||
"query_editor.text_to_sql.mode.replace_selection": "Заменить выделение",
|
||||
"query_editor.text_to_sql.placeholder": "Например: посчитать дневную сумму заказов за последние 30 дней и отсортировать по дате по возрастанию",
|
||||
"query_editor.text_to_sql.title": "Text-to-SQL",
|
||||
"query_editor.sql_error.rule.column_missing.explanation": "SQL ссылается на столбец, которого нет в наборе результатов, имя отличается по написанию или столбца нет в текущей таблице.",
|
||||
"query_editor.sql_error.rule.column_missing.label": "Столбец не существует",
|
||||
"query_editor.sql_error.rule.column_missing.suggestion": "Проверьте имена столбцов, алиасы, регистр, алиасы таблиц и принадлежность столбца текущему объекту FROM/JOIN.",
|
||||
|
||||
@@ -5954,6 +5954,7 @@
|
||||
"query_editor.action.ai_optimize_sql": "AI 优化 SQL",
|
||||
"query_editor.action.ai_optimize_sql_menu": "优化 SQL",
|
||||
"query_editor.action.ai_schema_analysis": "Schema 分析",
|
||||
"query_editor.action.ai_text_to_sql_menu": "Text-to-SQL",
|
||||
"query_editor.action.export_sql_file": "导出 SQL 文件",
|
||||
"query_editor.action.format": "美化",
|
||||
"query_editor.action.find_in_editor": "搜索",
|
||||
@@ -6177,6 +6178,9 @@
|
||||
"query_editor.message.format_failed": "格式化失败:SQL 语法可能有误。",
|
||||
"query_editor.message.format_restore_success": "已还原到美化前 SQL",
|
||||
"query_editor.message.insert_success": "代码已在当前光标处成功插入。",
|
||||
"query_editor.message.ai_model_missing": "当前 AI Provider 未选择模型,请先在 AI 设置中选择模型。",
|
||||
"query_editor.message.ai_provider_missing": "请先在 AI 设置中配置并启用 AI Provider。",
|
||||
"query_editor.message.ai_service_unavailable": "AI 服务尚未就绪,请稍后重试或检查 AI 设置。",
|
||||
"query_editor.message.no_executable_sql": "没有可执行的 SQL。",
|
||||
"query_editor.message.no_format_restore_snapshot": "没有可还原的美化前 SQL",
|
||||
"query_editor.message.current_line_no_copyable_content": "当前行没有可复制内容。",
|
||||
@@ -6199,6 +6203,10 @@
|
||||
"query_editor.message.select_database_first": "请先选择数据库。",
|
||||
"query_editor.message.sql_file_saved": "SQL 文件已保存。",
|
||||
"query_editor.message.statement_failed_prefix": "第 {{index}} 条语句执行失败:",
|
||||
"query_editor.message.text_to_sql_empty_instruction": "请输入要生成的 SQL 需求。",
|
||||
"query_editor.message.text_to_sql_empty_result": "AI 未返回可插入的 SQL。",
|
||||
"query_editor.message.text_to_sql_failed": "Text-to-SQL 生成失败:{{error}}",
|
||||
"query_editor.message.text_to_sql_success": "SQL 已生成并写入编辑器。",
|
||||
"query_editor.message.unsupported_source": "当前数据源不支持 SQL 查询编辑器,请使用对应专用页面。",
|
||||
"query_editor.object_info.column": "字段",
|
||||
"query_editor.object_info.database": "数据库",
|
||||
@@ -6270,6 +6278,13 @@
|
||||
"query_editor.slash_command.sql.description": "描述需求自动生成语句",
|
||||
"query_editor.slash_command.sql.label": "生成 SQL",
|
||||
"query_editor.slash_command.sql.prompt": "请根据以下需求生成 SQL:",
|
||||
"query_editor.text_to_sql.description": "描述你想查询或处理的数据,GoNavi 会结合当前连接、库和已加载表结构生成 SQL。",
|
||||
"query_editor.text_to_sql.generate": "生成 SQL",
|
||||
"query_editor.text_to_sql.mode.insert": "插入到光标",
|
||||
"query_editor.text_to_sql.mode.replace_all": "替换全文",
|
||||
"query_editor.text_to_sql.mode.replace_selection": "替换选区",
|
||||
"query_editor.text_to_sql.placeholder": "例如:统计最近 30 天每天的订单金额,并按日期升序返回",
|
||||
"query_editor.text_to_sql.title": "Text-to-SQL",
|
||||
"query_editor.sql_error.rule.column_missing.explanation": "SQL 引用了结果集中不存在、拼写不一致或当前表没有的字段。",
|
||||
"query_editor.sql_error.rule.column_missing.label": "字段不存在",
|
||||
"query_editor.sql_error.rule.column_missing.suggestion": "检查字段名、别名、大小写、引用表别名,以及字段是否属于当前 FROM/JOIN 的对象。",
|
||||
|
||||
@@ -5954,6 +5954,7 @@
|
||||
"query_editor.action.ai_optimize_sql": "AI 最佳化 SQL",
|
||||
"query_editor.action.ai_optimize_sql_menu": "最佳化 SQL",
|
||||
"query_editor.action.ai_schema_analysis": "結構描述分析",
|
||||
"query_editor.action.ai_text_to_sql_menu": "Text-to-SQL",
|
||||
"query_editor.action.export_sql_file": "匯出 SQL 檔案",
|
||||
"query_editor.action.format": "格式化",
|
||||
"query_editor.action.find_in_editor": "搜尋",
|
||||
@@ -6177,6 +6178,9 @@
|
||||
"query_editor.message.format_failed": "格式化失敗:SQL 語法可能無效。",
|
||||
"query_editor.message.format_restore_success": "已還原到美化前 SQL",
|
||||
"query_editor.message.insert_success": "程式碼已插入目前游標位置。",
|
||||
"query_editor.message.ai_model_missing": "目前 AI Provider 尚未選擇模型,請先在 AI 設定中選擇模型。",
|
||||
"query_editor.message.ai_provider_missing": "請先在 AI 設定中設定並啟用 AI Provider。",
|
||||
"query_editor.message.ai_service_unavailable": "AI 服務尚未就緒,請稍後重試或檢查 AI 設定。",
|
||||
"query_editor.message.no_executable_sql": "沒有可執行的 SQL。",
|
||||
"query_editor.message.no_format_restore_snapshot": "沒有可還原的美化前 SQL",
|
||||
"query_editor.message.current_line_no_copyable_content": "目前行沒有可複製內容。",
|
||||
@@ -6199,6 +6203,10 @@
|
||||
"query_editor.message.select_database_first": "請先選擇資料庫。",
|
||||
"query_editor.message.sql_file_saved": "SQL 檔案已儲存。",
|
||||
"query_editor.message.statement_failed_prefix": "第 {{index}} 個陳述式執行失敗:",
|
||||
"query_editor.message.text_to_sql_empty_instruction": "請輸入要產生的 SQL 需求。",
|
||||
"query_editor.message.text_to_sql_empty_result": "AI 未傳回可插入的 SQL。",
|
||||
"query_editor.message.text_to_sql_failed": "Text-to-SQL 產生失敗:{{error}}",
|
||||
"query_editor.message.text_to_sql_success": "SQL 已產生並寫入編輯器。",
|
||||
"query_editor.message.unsupported_source": "此資料來源不支援 SQL 查詢編輯器。請改用其專用頁面。",
|
||||
"query_editor.object_info.column": "欄位",
|
||||
"query_editor.object_info.database": "資料庫",
|
||||
@@ -6270,6 +6278,13 @@
|
||||
"query_editor.slash_command.sql.description": "描述需求並產生陳述式",
|
||||
"query_editor.slash_command.sql.label": "產生 SQL",
|
||||
"query_editor.slash_command.sql.prompt": "請根據以下需求產生 SQL:",
|
||||
"query_editor.text_to_sql.description": "描述你想查詢或處理的資料,GoNavi 會結合目前連線、資料庫和已載入表結構產生 SQL。",
|
||||
"query_editor.text_to_sql.generate": "產生 SQL",
|
||||
"query_editor.text_to_sql.mode.insert": "插入到游標",
|
||||
"query_editor.text_to_sql.mode.replace_all": "取代全文",
|
||||
"query_editor.text_to_sql.mode.replace_selection": "取代選取範圍",
|
||||
"query_editor.text_to_sql.placeholder": "例如:統計最近 30 天每天的訂單金額,並按日期升冪返回",
|
||||
"query_editor.text_to_sql.title": "Text-to-SQL",
|
||||
"query_editor.sql_error.rule.column_missing.explanation": "SQL 引用了結果集中不存在、拼寫不一致或目前表沒有的欄位。",
|
||||
"query_editor.sql_error.rule.column_missing.label": "欄位不存在",
|
||||
"query_editor.sql_error.rule.column_missing.suggestion": "檢查欄位名、別名、大小寫、引用表別名,以及欄位是否屬於目前 FROM/JOIN 的物件。",
|
||||
|
||||
Reference in New Issue
Block a user