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