feat(query-editor): 支持 AI 行内补全与文本生成 SQL

- 新增 SQL 编辑器 AI 辅助服务,接入已配置供应商并生成行内补全

- 增加 Text-to-SQL 弹窗与插入、替换选区、替换全文应用模式

- 补齐多语言文案和定向测试覆盖
This commit is contained in:
Syngnat
2026-07-02 21:36:44 +08:00
parent b0f077ffbb
commit 5eafd2aa3c
12 changed files with 1098 additions and 6 deletions

View File

@@ -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}

View File

@@ -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',

View File

@@ -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"),
},

View 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');
});
});

View 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();
};

View File

@@ -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,

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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 オブジェクトに属しているか確認してください。",

View File

@@ -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.",

View File

@@ -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 的对象。",

View File

@@ -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 的物件。",