feat(query-editor): 增强 SQL 编辑器 AI 内联补全与独立模型配置

- 新增独立内联补全模型配置与服务端透传
- 优化 SQL 编辑器 Alt+\\ 触发、ghost 延续与对象位补全
- 引入基于已存查询和执行日志的 SQL 记忆补全
- 补充前后端本地化、快捷键与补全回归测试
This commit is contained in:
Syngnat
2026-07-03 20:44:33 +08:00
parent 5eafd2aa3c
commit 29ee21e9d7
32 changed files with 4399 additions and 209 deletions

View File

@@ -374,6 +374,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
valuesModel: values.model,
customModels: values.models,
});
const inlineCompletionModel = String(values.inlineCompletionModel || '').trim();
// 内置供应商自动使用 preset label 作为名称
const finalName = isCustomLike ? (values.name || localizedPreset.label) : localizedPreset.label;
@@ -398,6 +399,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
apiKey: secretDraft.apiKey,
hasSecret: secretDraft.hasSecret,
model: finalModel,
inlineCompletionModel,
models: resolvedModels,
baseUrl: finalBaseUrl,
apiFormat: resolvedTransport.apiFormat,
@@ -669,6 +671,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
hasSecret: secretDraft.hasSecret,
baseUrl: finalBaseUrl,
model: finalModel,
inlineCompletionModel: String(values.inlineCompletionModel || '').trim(),
models: resolvedModels,
maxTokens: Number(values.maxTokens) || 4096,
temperature: Number(values.temperature) ?? 0.7,
@@ -687,12 +690,20 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: form.getFieldValue('apiFormat'),
});
const { model: presetModel, models: presetModels } = resolvePresetModelSelection({
presetKey,
presetDefaultModel: preset.defaultModel,
presetModels: preset.models,
customModels: preset.models,
});
form.setFieldsValue({
presetKey,
type: resolvedTransport.type,
apiFormat: resolvedTransport.apiFormat || 'openai',
baseUrl: preset.defaultBaseUrl,
model: preset.defaultModel,
model: presetModel,
models: presetModels,
inlineCompletionModel: '',
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const source = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8');
describe('QueryEditorToolbar AI trigger affordance', () => {
it('keeps a direct toolbar trigger for inline AI completion', () => {
expect(source).toContain('onMouseDown={onCaptureEditorCursorPosition}');
expect(source).toContain('onClick={onTriggerSqlAiCompletion}');
expect(source).toContain('triggerSqlAiCompletionLabel');
});
it('keeps the secondary AI dropdown for other actions', () => {
expect(source).toContain('icon={<DownOutlined />}');
expect(source).toContain('menu={{ items: aiMenuItems }}');
});
});

View File

@@ -1,6 +1,7 @@
import React from "react";
import { Button, Dropdown, Select, Tooltip, type MenuProps } from "antd";
import {
DownOutlined,
EyeInvisibleOutlined,
EyeOutlined,
FormatPainterOutlined,
@@ -37,6 +38,7 @@ type QueryEditorToolbarProps = {
runQueryShortcutBinding: ShortcutPlatformBinding;
saveQueryShortcutBinding: ShortcutPlatformBinding;
formatSqlShortcutBinding: ShortcutPlatformBinding;
triggerSqlAiCompletionShortcutBinding: ShortcutPlatformBinding;
toggleQueryResultsPanelShortcutBinding: ShortcutPlatformBinding;
activeShortcutPlatform: ShortcutPlatform;
isResultPanelVisible: boolean;
@@ -54,6 +56,7 @@ type QueryEditorToolbarProps = {
onQuickSave: () => void;
onFindInEditor: () => void;
onFormat: () => void;
onTriggerSqlAiCompletion: () => void;
onToggleResultPanelVisibility: () => void;
onAIAction: (action: "generate" | "explain" | "optimize" | "schema") => void;
};
@@ -99,6 +102,7 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
runQueryShortcutBinding,
saveQueryShortcutBinding,
formatSqlShortcutBinding,
triggerSqlAiCompletionShortcutBinding,
toggleQueryResultsPanelShortcutBinding,
activeShortcutPlatform,
isResultPanelVisible,
@@ -116,6 +120,7 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
onQuickSave,
onFindInEditor,
onFormat,
onTriggerSqlAiCompletion,
onToggleResultPanelVisibility,
onAIAction,
}) => {
@@ -175,7 +180,22 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
),
},
);
const triggerSqlAiCompletionLabel =
triggerSqlAiCompletionShortcutBinding.enabled &&
triggerSqlAiCompletionShortcutBinding.combo
? `${t("app.shortcuts.action.triggerSqlAiCompletion.label")} · ${getShortcutDisplayLabel(
triggerSqlAiCompletionShortcutBinding.combo,
activeShortcutPlatform,
)}`
: t("app.shortcuts.action.triggerSqlAiCompletion.label");
const aiMenuItems: MenuProps["items"] = [
{
key: "ai-inline-completion",
label: triggerSqlAiCompletionLabel,
icon: <RobotOutlined />,
onClick: onTriggerSqlAiCompletion,
},
{ type: "divider" as const },
{
key: "ai-generate",
label: t("query_editor.action.ai_text_to_sql_menu"),
@@ -359,19 +379,30 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
{t("query_editor.action.save")}
</Button>
</Tooltip>
<Dropdown
menu={{ items: aiMenuItems }}
placement="bottomRight"
trigger={["click"]}
>
<Button
className={isV2Ui ? "gn-v2-query-toolbar-ai-action" : undefined}
icon={<RobotOutlined />}
style={{ color: "#818cf8" }}
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<Tooltip title={triggerSqlAiCompletionLabel}>
<Button
className={isV2Ui ? "gn-v2-query-toolbar-ai-action" : undefined}
icon={<RobotOutlined />}
style={{ color: "#818cf8" }}
onMouseDown={onCaptureEditorCursorPosition}
onClick={onTriggerSqlAiCompletion}
>
AI
</Button>
</Tooltip>
<Dropdown
menu={{ items: aiMenuItems }}
placement="bottomRight"
trigger={["click"]}
>
AI
</Button>
</Dropdown>
<Button
className={isV2Ui ? "gn-v2-query-toolbar-icon-action" : undefined}
icon={<DownOutlined />}
aria-label="AI more actions"
/>
</Dropdown>
</div>
<Dropdown
menu={{ items: moreMenuItems }}
placement="bottomRight"

View File

@@ -38,6 +38,10 @@ const REQUIRED_PROVIDER_FORM_KEYS = [
'ai_settings.form.model_list_placeholder',
'ai_settings.form.model_list_placeholder.codebuddy',
'ai_settings.form.model_list_placeholder.cursor',
'ai_settings.form.section.inline_completion',
'ai_settings.form.inline_completion_model',
'ai_settings.form.inline_completion_model_hint',
'ai_settings.form.inline_completion_model_placeholder',
'ai_settings.form.api_key',
'ai_settings.form.api_key.codebuddy_optional',
'ai_settings.form.api_key.codebuddy_hint',
@@ -158,6 +162,8 @@ describe('AISettingsProvidersSection', () => {
const markup = renderToStaticMarkup(<Wrap />);
expect(markup).toContain('Edit model provider');
expect(markup).toContain('Provider name');
expect(markup).toContain('SQL auto-completion');
expect(markup).toContain('Auto-completion model');
expect(markup).toContain('API Endpoint (URL)');
expect(markup).toContain('Test connection');
});

View File

@@ -21,6 +21,8 @@ export interface AISettingsProviderPresetOption {
icon: React.ReactNode;
desc: string;
defaultBaseUrl: string;
defaultModel?: string;
models?: string[];
}
interface MatchedProviderPreset {
@@ -116,6 +118,31 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
const currentFieldGroupStyle = fieldGroupStyle(cardBorder, cardBg);
const currentFieldLabelStyle = fieldLabelStyle(sectionLabelColor);
const watchedModel = Form.useWatch('model', form);
const watchedModels = Form.useWatch('models', form);
const watchedInlineCompletionModel = Form.useWatch('inlineCompletionModel', form);
const presetFromForm = providerPresets.find((preset) => preset.key === presetKeyFromForm);
const inlineCompletionModelOptions = React.useMemo(() => {
const values = [
watchedModel,
presetFromForm?.defaultModel,
...(Array.isArray(presetFromForm?.models) ? presetFromForm.models : []),
...(Array.isArray(watchedModels) ? watchedModels : []),
editingProvider?.inlineCompletionModel,
watchedInlineCompletionModel,
];
const deduped = new Set<string>();
values.forEach((value) => {
const normalized = String(value || '').trim();
if (normalized) {
deduped.add(normalized);
}
});
return Array.from(deduped).map((value) => ({
label: value,
value,
}));
}, [editingProvider?.inlineCompletionModel, presetFromForm?.defaultModel, presetFromForm?.models, watchedInlineCompletionModel, watchedModel, watchedModels]);
if (!isEditing) {
return (
@@ -343,6 +370,28 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
<Form.Item name="model" hidden><Input /></Form.Item>
<Form.Item name="name" hidden><Input /></Form.Item>
<div style={{ ...currentFieldGroupStyle, marginTop: 16 }}>
<div style={currentFieldLabelStyle}>
<RobotOutlined style={{ fontSize: 14 }} /> {copy('ai_settings.form.section.inline_completion')}
</div>
<Form.Item
label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>{copy('ai_settings.form.inline_completion_model')}</span>}
name="inlineCompletionModel"
extra={copy('ai_settings.form.inline_completion_model_hint')}
style={{ marginBottom: 0 }}
>
<Select
allowClear
showSearch
optionFilterProp="label"
size="middle"
placeholder={copy('ai_settings.form.inline_completion_model_placeholder')}
options={inlineCompletionModelOptions}
style={{ width: '100%' }}
/>
</Form.Item>
</div>
<div style={{ ...currentFieldGroupStyle, marginTop: 16 }}>
<div style={currentFieldLabelStyle}>
<KeyOutlined style={{ fontSize: 14 }} /> {copy('ai_settings.form.section.auth_connection')}

View File

@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildQueryEditorAiInlineSuggestOptions,
buildQueryEditorInlineCompletionMessages,
buildQueryEditorInlineCompletionContext,
buildQueryEditorTextToSqlMessages,
requestQueryEditorInlineCompletion,
resolveInlineSqlGhostPreviewText,
resolveInlineSqlInsertText,
resolveQueryEditorAiRuntimeReadiness,
resolveQueryEditorInlineMemoryInsertText,
resolveQueryEditorInlineCompletionModel,
resolveQueryEditorInlineCompletionIntentDetails,
sanitizeSqlAssistantResponse,
shouldAllowQueryEditorInlineMemoryCompletion,
shouldTriggerQueryEditorInlineObjectSuggestFallback,
shouldRequestQueryEditorInlineCompletion,
type QueryEditorAiService,
} from './QueryEditorAiAssist';
@@ -32,6 +40,17 @@ const readyService = (content = 'SELECT * FROM users;'): QueryEditorAiService =>
});
describe('QueryEditorAiAssist', () => {
it('keeps AI inline suggestions visible when normal SQL suggestions are open', () => {
expect(buildQueryEditorAiInlineSuggestOptions()).toMatchObject({
enabled: true,
mode: 'prefix',
suppressSuggestions: true,
experimental: {
showOnSuggestConflict: 'always',
},
});
});
it('only requests inline completion in editable SQL context', () => {
expect(shouldRequestQueryEditorInlineCompletion({
prefix: 'select',
@@ -62,6 +81,41 @@ describe('QueryEditorAiAssist', () => {
})).toBe(false);
});
it('allows inline memory completion in empty or prefix-only editable SQL context', () => {
expect(shouldAllowQueryEditorInlineMemoryCompletion({
prefix: '',
suffix: '',
currentLineBeforeCursor: '',
currentLineAfterCursor: '',
})).toBe(true);
expect(resolveQueryEditorInlineMemoryInsertText({
editorSnapshot: {
prefix: '',
suffix: '',
currentLineBeforeCursor: '',
currentLineAfterCursor: '',
},
memoryEntries: [
{ sql: 'SELECT * FROM videos WHERE code = ?;' },
{ sql: 'UPDATE videos SET status = 1 WHERE id = ?;' },
],
})).toBe('SELECT * FROM videos WHERE code = ?;');
expect(resolveQueryEditorInlineMemoryInsertText({
editorSnapshot: {
prefix: 'UPDATE',
suffix: '',
currentLineBeforeCursor: 'UPDATE',
currentLineAfterCursor: '',
},
memoryEntries: [
{ sql: 'SELECT * FROM videos WHERE code = ?;' },
{ sql: 'UPDATE videos SET status = 1 WHERE id = ?;' },
],
})).toBe(' videos SET status = 1 WHERE id = ?;');
});
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;');
@@ -69,18 +123,243 @@ describe('QueryEditorAiAssist', () => {
expect(resolveInlineSqlInsertText('SELECT * FROM users;', 'select')).toBe(' * FROM users;');
expect(resolveInlineSqlInsertText('from users;', 'select ')).toBe('from users;');
expect(resolveInlineSqlInsertText('orders', 'select * from')).toBe(' orders');
expect(resolveInlineSqlGhostPreviewText(' * FROM users\nWHERE id = 1;')).toBe(' * FROM users WHERE id = 1;');
});
it('treats stray non-identifier markers in object-name positions as an empty fragment', () => {
expect(resolveQueryEditorInlineCompletionIntentDetails({
prefix: 'SELECT * FROM \\',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM \\',
currentLineAfterCursor: '',
})).toEqual({
intent: 'table_name',
fragment: '',
qualifier: '',
});
expect(resolveQueryEditorInlineCompletionIntentDetails({
prefix: 'SELECT * FROM videos v WHERE v.\\',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM videos v WHERE v.\\',
currentLineAfterCursor: '',
})).toEqual({
intent: 'column_name',
fragment: '',
qualifier: 'v',
});
expect(resolveQueryEditorInlineCompletionIntentDetails({
prefix: 'ALTER TABLE \\',
suffix: '',
currentLineBeforeCursor: 'ALTER TABLE \\',
currentLineAfterCursor: '',
})).toEqual({
intent: 'table_name',
fragment: '',
qualifier: '',
});
});
it('only keeps object-name suggest fallback for unresolved inline object positions', () => {
expect(shouldTriggerQueryEditorInlineObjectSuggestFallback({
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [
{ dbName: 'shop', tableName: 'videos' },
{ dbName: 'shop', tableName: 'visits' },
],
columns: [],
},
editorSnapshot: {
prefix: 'SELECT * FROM ',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM ',
currentLineAfterCursor: '',
},
})).toBe(true);
expect(shouldTriggerQueryEditorInlineObjectSuggestFallback({
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [
{ dbName: 'shop', tableName: 'videos' },
{ dbName: 'shop', tableName: 'visits' },
],
columns: [],
},
editorSnapshot: {
prefix: 'SELECT * FROM videos',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM videos',
currentLineAfterCursor: '',
},
})).toBe(false);
});
it('uses deterministic SQL skeletons for weak keyword-only contexts and skips AI', async () => {
const service = readyService('select * from users;');
await expect(requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'users' }],
columns: [{ dbName: 'shop', tableName: 'users', name: 'id', type: 'bigint' }],
},
editorSnapshot: {
prefix: 'SELECT',
suffix: '',
currentLineBeforeCursor: 'SELECT',
currentLineAfterCursor: '',
},
})).resolves.toBe(' * FROM ');
await expect(requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'users' }],
columns: [{ dbName: 'shop', tableName: 'users', name: 'id', type: 'bigint' }],
},
editorSnapshot: {
prefix: 'DELETE',
suffix: '',
currentLineBeforeCursor: 'DELETE',
currentLineAfterCursor: '',
},
})).resolves.toBe(' FROM ');
await expect(requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'users' }],
columns: [{ dbName: 'shop', tableName: 'users', name: 'id', type: 'bigint' }],
},
editorSnapshot: {
prefix: 'MERGE',
suffix: '',
currentLineBeforeCursor: 'MERGE',
currentLineAfterCursor: '',
},
})).resolves.toBe(' INTO ');
await expect(requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'users' }],
columns: [{ dbName: 'shop', tableName: 'users', name: 'id', type: 'bigint' }],
},
editorSnapshot: {
prefix: 'REPLACE',
suffix: '',
currentLineBeforeCursor: 'REPLACE',
currentLineAfterCursor: '',
},
})).resolves.toBe(' INTO ');
await expect(requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'users' }],
columns: [{ dbName: 'shop', tableName: 'users', name: 'id', type: 'bigint' }],
},
editorSnapshot: {
prefix: 'ALTER',
suffix: '',
currentLineBeforeCursor: 'ALTER',
currentLineAfterCursor: '',
},
})).resolves.toBe(' TABLE ');
await expect(requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'users' }],
columns: [{ dbName: 'shop', tableName: 'users', name: 'id', type: 'bigint' }],
},
editorSnapshot: {
prefix: 'CREATE',
suffix: '',
currentLineBeforeCursor: 'CREATE',
currentLineAfterCursor: '',
},
})).resolves.toBe(' TABLE ');
await expect(requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'users' }],
columns: [{ dbName: 'shop', tableName: 'users', name: 'id', type: 'bigint' }],
},
editorSnapshot: {
prefix: 'DROP',
suffix: '',
currentLineBeforeCursor: 'DROP',
currentLineAfterCursor: '',
},
})).resolves.toBe(' TABLE ');
await expect(requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'users' }],
columns: [{ dbName: 'shop', tableName: 'users', name: 'id', type: 'bigint' }],
},
editorSnapshot: {
prefix: 'TRUNCATE',
suffix: '',
currentLineBeforeCursor: 'TRUNCATE',
currentLineAfterCursor: '',
},
})).resolves.toBe(' TABLE ');
expect(service.AIChatSend).not.toHaveBeenCalled();
});
it('builds inline and text-to-sql prompts with custom instructions and schema hints', () => {
const aiContext = {
connectionName: 'Local MySQL',
host: '127.0.0.1',
port: 3306,
sourceType: 'mysql',
currentDb: 'shop',
visibleDbs: ['shop'],
tables: [{ dbName: 'shop', tableName: 'orders', comment: 'sales orders' }],
tables: [
{ dbName: 'shop', tableName: 'orders', comment: 'sales orders' },
{ dbName: 'shop', tableName: 'videos', comment: 'media table' },
],
columns: [
{ dbName: 'shop', tableName: 'orders', name: 'id', type: 'bigint' },
{ dbName: 'shop', tableName: 'orders', name: 'amount', type: 'decimal' },
{ dbName: 'shop', tableName: 'videos', name: 'code', type: 'varchar' },
],
};
const userPromptSettings = {
@@ -93,16 +372,21 @@ describe('QueryEditorAiAssist', () => {
const inlineMessages = buildQueryEditorInlineCompletionMessages({
aiContext,
editorSnapshot: {
prefix: 'select',
prefix: 'select * from videos v where v.',
suffix: '',
currentLineBeforeCursor: 'select',
currentLineBeforeCursor: 'select * from videos v where v.',
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('- host: 127.0.0.1:3306');
expect(inlineJoined).toContain('- current_statement_tables: shop.videos AS v');
expect(inlineJoined).toContain('- inline_completion_intent: column_name');
expect(inlineJoined).toContain('- inline_completion_qualifier: v');
expect(inlineJoined).toContain('shop.videos -- media table; columns: code varchar');
expect(inlineJoined).not.toContain('shop.orders -- sales orders');
expect(inlineJoined).toContain('<prefix_before_cursor>');
const textToSqlMessages = buildQueryEditorTextToSqlMessages({
@@ -119,8 +403,42 @@ describe('QueryEditorAiAssist', () => {
expect(textToSqlMessages.map((message) => message.content).join('\n')).toContain('total order amount by day');
});
it('focuses inline schema hints on referenced tables or the current database', () => {
const focused = buildQueryEditorInlineCompletionContext({
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
visibleDbs: ['shop'],
tables: [
{ dbName: 'shop', tableName: 'orders' },
{ dbName: 'shop', tableName: 'videos' },
{ dbName: 'archive', tableName: 'videos' },
],
columns: [
{ dbName: 'shop', tableName: 'orders', name: 'id', type: 'bigint' },
{ dbName: 'shop', tableName: 'videos', name: 'code', type: 'varchar' },
{ dbName: 'archive', tableName: 'videos', name: 'legacy_code', type: 'varchar' },
],
}, {
prefix: 'select * from videos v where',
suffix: '',
currentLineBeforeCursor: 'select * from videos v where',
currentLineAfterCursor: '',
});
expect(focused.inlineSchemaScope).toBe('referenced_tables');
expect(focused.inlineReferencedTables).toEqual([{
dbName: 'shop',
tableName: 'videos',
alias: 'v',
raw: 'videos',
}]);
expect(focused.tables).toEqual([{ dbName: 'shop', tableName: 'videos' }]);
expect(focused.columns).toEqual([{ dbName: 'shop', tableName: 'videos', name: 'code', type: 'varchar' }]);
});
it('checks active provider readiness before inline AI requests', async () => {
const service = readyService('select * from users;');
const service = readyService('select * from users where id > 1;');
const readiness = await resolveQueryEditorAiRuntimeReadiness(service);
expect(readiness.ready).toBe(true);
expect(readiness.provider?.model).toBe('gpt-5');
@@ -131,17 +449,17 @@ describe('QueryEditorAiAssist', () => {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [],
columns: [],
tables: [{ dbName: 'shop', tableName: 'users' }],
columns: [{ dbName: 'shop', tableName: 'users', name: 'id', type: 'bigint' }],
},
editorSnapshot: {
prefix: 'select',
prefix: 'select * from users ',
suffix: '',
currentLineBeforeCursor: 'select',
currentLineBeforeCursor: 'select * from users ',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe(' * from users;');
expect(insertText).toBe('where id > 1;');
expect(service.AIChatSend).toHaveBeenCalledTimes(1);
const missingProvider = await resolveQueryEditorAiRuntimeReadiness({
@@ -152,4 +470,322 @@ describe('QueryEditorAiAssist', () => {
expect(missingProvider.ready).toBe(false);
expect(missingProvider.reason).toBe('provider_missing');
});
it('uses deterministic schema metadata for table-name inline completion and skips AI', async () => {
const service = readyService('SELECT * FROM orders;');
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [
{ dbName: 'shop', tableName: 'videos' },
{ dbName: 'shop', tableName: 'orders' },
],
columns: [],
},
editorSnapshot: {
prefix: 'SELECT * FROM vid',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM vid',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe('eos');
expect(service.AIChatSend).not.toHaveBeenCalled();
});
it('uses deterministic schema metadata for alter-table inline completion and skips AI', async () => {
const service = readyService('ALTER TABLE orders ADD COLUMN status INT;');
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [
{ dbName: 'shop', tableName: 'videos' },
{ dbName: 'shop', tableName: 'orders' },
],
columns: [],
},
editorSnapshot: {
prefix: 'ALTER TABLE ord',
suffix: '',
currentLineBeforeCursor: 'ALTER TABLE ord',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe('ers');
expect(service.AIChatSend).not.toHaveBeenCalled();
});
it('uses grounded AI for ambiguous table-name inline completion when the suggestion matches schema metadata', async () => {
const service = readyService('videos');
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [
{ dbName: 'shop', tableName: 'videos' },
{ dbName: 'shop', tableName: 'visits' },
],
columns: [],
},
editorSnapshot: {
prefix: 'SELECT * FROM vi',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM vi',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe('deos');
expect(service.AIChatSend).toHaveBeenCalledTimes(1);
});
it('rejects ungrounded AI table-name inline completion when the suggestion is outside schema metadata', async () => {
const service = readyService('Japgolly');
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [
{ dbName: 'shop', tableName: 'videos' },
{ dbName: 'shop', tableName: 'visits' },
],
columns: [],
},
editorSnapshot: {
prefix: 'SELECT * FROM \\',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM \\',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe('');
expect(service.AIChatSend).toHaveBeenCalledTimes(1);
});
it('uses deterministic schema metadata for alias column inline completion and skips AI', async () => {
const service = readyService('SELECT * FROM videos;');
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'videos' }],
columns: [
{ dbName: 'shop', tableName: 'videos', name: 'code', type: 'varchar' },
{ dbName: 'shop', tableName: 'videos', name: 'created_at', type: 'datetime' },
],
},
editorSnapshot: {
prefix: 'SELECT v.co FROM videos v WHERE v.co',
suffix: '',
currentLineBeforeCursor: 'SELECT v.co FROM videos v WHERE v.co',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe('de');
expect(service.AIChatSend).not.toHaveBeenCalled();
});
it('uses grounded AI for ambiguous column-name inline completion when the suggestion matches table metadata', async () => {
const service = readyService('code');
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'videos' }],
columns: [
{ dbName: 'shop', tableName: 'videos', name: 'code', type: 'varchar' },
{ dbName: 'shop', tableName: 'videos', name: 'created_at', type: 'datetime' },
],
},
editorSnapshot: {
prefix: 'SELECT * FROM videos v WHERE v.c',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM videos v WHERE v.c',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe('ode');
expect(service.AIChatSend).toHaveBeenCalledTimes(1);
});
it('rejects ungrounded AI column-name inline completion when the suggestion is outside table metadata', async () => {
const service = readyService('checksum');
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'videos' }],
columns: [
{ dbName: 'shop', tableName: 'videos', name: 'code', type: 'varchar' },
{ dbName: 'shop', tableName: 'videos', name: 'created_at', type: 'datetime' },
],
},
editorSnapshot: {
prefix: 'SELECT * FROM videos v WHERE v.c',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM videos v WHERE v.c',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe('');
expect(service.AIChatSend).toHaveBeenCalledTimes(1);
});
it('uses the dedicated inline completion model when configured', async () => {
const service = {
...readyService('select * from users;'),
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',
inlineCompletionModel: 'gpt-5-mini',
maxTokens: 2048,
temperature: 0.2,
}]),
AIChatSendWithOptions: vi.fn(async () => ({ success: true, content: 'select * from users where id = 1;' })),
};
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'users' }],
columns: [{ dbName: 'shop', tableName: 'users', name: 'id', type: 'bigint' }],
},
editorSnapshot: {
prefix: 'select * from users ',
suffix: '',
currentLineBeforeCursor: 'select * from users ',
currentLineAfterCursor: '',
},
});
expect(resolveQueryEditorInlineCompletionModel((await service.AIGetProviders())[0])).toBe('gpt-5-mini');
expect(insertText).toBe('where id = 1;');
expect(service.AIChatSend).not.toHaveBeenCalled();
expect(service.AIChatSendWithOptions).toHaveBeenCalledWith(expect.any(Array), [], {
model: 'gpt-5-mini',
maxTokens: 192,
temperature: 0.1,
});
});
it('falls back to the chat model for inline completion when no dedicated model is configured', async () => {
const service = {
...readyService('select * from users;'),
AIChatSendWithOptions: vi.fn(async () => ({ success: true, content: 'select * from users where id = 1;' })),
};
await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'users' }],
columns: [{ dbName: 'shop', tableName: 'users', name: 'id', type: 'bigint' }],
},
editorSnapshot: {
prefix: 'select * from users ',
suffix: '',
currentLineBeforeCursor: 'select * from users ',
currentLineAfterCursor: '',
},
});
expect(service.AIChatSendWithOptions).toHaveBeenCalledWith(expect.any(Array), [], expect.objectContaining({
model: 'gpt-5',
}));
});
it('uses reasoning content as a fallback for inline completion responses', async () => {
const service = {
...readyService(''),
AIChatSend: vi.fn(async () => ({
success: true,
content: '',
reasoning_content: 'SELECT * FROM videos WHERE code IS NOT NULL;',
})),
};
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'videos' }],
columns: [{ dbName: 'shop', tableName: 'videos', name: 'code', type: 'varchar' }],
},
editorSnapshot: {
prefix: 'select * from videos ',
suffix: '',
currentLineBeforeCursor: 'select * from videos ',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe('WHERE code IS NOT NULL;');
});
it('drops inline completions that introduce tables outside the selected database context', async () => {
const service = {
...readyService('select * from orders where id = 1;'),
AIChatSendWithOptions: vi.fn(async () => ({ success: true, content: 'select * from orders where id = 1;' })),
};
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [{ dbName: 'shop', tableName: 'videos' }],
columns: [{ dbName: 'shop', tableName: 'videos', name: 'code', type: 'varchar' }],
},
editorSnapshot: {
prefix: 'select * from videos ',
suffix: '',
currentLineBeforeCursor: 'select * from videos ',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe('');
});
});

File diff suppressed because it is too large Load Diff