mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-21 12:51:47 +08:00
✨ feat(query-editor): 增强 SQL 编辑器 AI 内联补全与独立模型配置
- 新增独立内联补全模型配置与服务端透传 - 优化 SQL 编辑器 Alt+\\ 触发、ghost 延续与对象位补全 - 引入基于已存查询和执行日志的 SQL 记忆补全 - 补充前后端本地化、快捷键与补全回归测试
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 }}');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user