diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index db8960c8..f78874af 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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 = {}; +let sharedActiveEditorModelUri = ''; +let sharedAiInlineCompletionRequestSeq = 0; const sharedLazyTablesCache: Record = {}; const sharedLazyTablesInFlight: Record | 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(tab.connectionId); const [currentDb, setCurrentDb] = useState(tab.dbName || ''); const [dbList, setDbList] = useState([]); + const [isTextToSqlModalOpen, setIsTextToSqlModalOpen] = useState(false); + const [textToSqlInstruction, setTextToSqlInstruction] = useState(''); + const [textToSqlApplyMode, setTextToSqlApplyMode] = useState('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 /> )} + { + if (!textToSqlGenerating) { + setIsTextToSqlModalOpen(false); + } + }} + footer={[ + , + , + ]} + 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, + }, + }} + > +
+
+ {translate('query_editor.text_to_sql.description')} +
+ setTextToSqlInstruction(event.target.value)} + placeholder={translate('query_editor.text_to_sql.placeholder')} + autoSize={{ minRows: 5, maxRows: 10 }} + disabled={textToSqlGenerating} + /> + 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' }, + ]} + /> +
+
+ = ({ 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: , onClick: () => onAIAction("generate"), }, diff --git a/frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts b/frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts new file mode 100644 index 00000000..65d4ae1c --- /dev/null +++ b/frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts @@ -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(''); + + 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'); + }); +}); diff --git a/frontend/src/components/queryEditor/QueryEditorAiAssist.ts b/frontend/src/components/queryEditor/QueryEditorAiAssist.ts new file mode 100644 index 00000000..0aebc830 --- /dev/null +++ b/frontend/src/components/queryEditor/QueryEditorAiAssist.ts @@ -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; + AIGetActiveProvider?: () => Promise; + AIGetUserPromptSettings?: () => Promise>; + AIChatSend?: (messages: QueryEditorAiMessage[], tools?: any[]) => Promise>; +} + +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 => { + 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 => { + 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:', + '', + truncateHead(editorSnapshot.prefix, INLINE_PREFIX_LIMIT), + '', + '', + truncateTail(editorSnapshot.suffix, INLINE_SUFFIX_LIMIT), + '', + '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:', + '', + truncateHead(editorSnapshot.prefix, TEXT_TO_SQL_PREFIX_LIMIT), + '', + '', + truncateTail(editorSnapshot.suffix, TEXT_TO_SQL_SUFFIX_LIMIT), + '', + ].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(); + (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(); +}; diff --git a/frontend/src/i18n/catalog.test.ts b/frontend/src/i18n/catalog.test.ts index a15382a0..c0581aa1 100644 --- a/frontend/src/i18n/catalog.test.ts +++ b/frontend/src/i18n/catalog.test.ts @@ -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, diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 5baf5bda..f7ec7d50 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -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.", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index c259ed21..437ecced 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -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.", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 853aac95..a975fc45 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -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 オブジェクトに属しているか確認してください。", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 98436e46..a2120db4 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -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.", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 1ec14197..290b9dc9 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -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 的对象。", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index b230eab2..45efb456 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -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 的物件。",