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

@@ -154,6 +154,7 @@ describe('shortcut localization', () => {
expect(SHORTCUT_ACTION_META.runQuery.label).toBe('Run SQL');
expect(SHORTCUT_ACTION_META.saveQuery.description).toBe('Save the current query tab; unnamed queries open the save dialog');
expect(SHORTCUT_ACTION_META.formatSql.label).toBe('Format SQL');
expect(SHORTCUT_ACTION_META.triggerSqlAiCompletion.label).toBe('Trigger SQL AI Completion');
expect(SHORTCUT_ACTION_META.toggleQueryResultsPanel.label).toBe('Toggle Results Panel');
expect(SHORTCUT_ACTION_META.toggleQueryResultsPanel.description).toBe('Show or hide the results area below the query editor');
expect(SHORTCUT_ACTION_META.sendAIChatMessage.description).toContain('Shift+Enter');
@@ -168,6 +169,7 @@ describe('shortcut localization', () => {
setCurrentLanguage('zh-CN');
expect(SHORTCUT_ACTION_META.runQuery.label).toBe('执行 SQL');
expect(SHORTCUT_ACTION_META.formatSql.label).toBe('美化 SQL');
expect(SHORTCUT_ACTION_META.triggerSqlAiCompletion.label).toBe('触发 SQL AI 自动补全');
expect(findReservedConflict('Ctrl+S')?.label).toBe('浏览器保存');
} finally {
setCurrentLanguage('zh-CN');
@@ -265,6 +267,54 @@ describe('IME shortcut guards', () => {
expect(isShortcutMatch(event, 'Ctrl+Enter')).toBe(false);
});
it('matches modifier shortcuts from KeyboardEvent.code when WebView reports Process', () => {
const event = {
key: 'Process',
code: 'Backslash',
keyCode: 220,
which: 220,
ctrlKey: false,
metaKey: false,
altKey: true,
shiftKey: false,
isComposing: false,
nativeEvent: {
code: 'Backslash',
keyCode: 220,
which: 220,
isComposing: false,
},
} as unknown as KeyboardEvent;
expect(isImeComposingKeyEvent(event)).toBe(false);
expect(eventToShortcut(event)).toBe('Alt+\\');
expect(isShortcutMatch(event, 'Alt+\\')).toBe(true);
});
it('matches modifier shortcuts from IntlBackslash layout events when WebView reports Process', () => {
const event = {
key: 'Process',
code: 'IntlBackslash',
keyCode: 226,
which: 226,
ctrlKey: false,
metaKey: false,
altKey: true,
shiftKey: false,
isComposing: false,
nativeEvent: {
code: 'IntlBackslash',
keyCode: 226,
which: 226,
isComposing: false,
},
} as unknown as KeyboardEvent;
expect(isImeComposingKeyEvent(event)).toBe(false);
expect(eventToShortcut(event)).toBe('Alt+\\');
expect(isShortcutMatch(event, 'Alt+\\')).toBe(true);
});
it('treats number keys as non-shortcuts while a composition session is active', () => {
setGlobalImeCompositionActive(true);
const event = {
@@ -366,6 +416,18 @@ describe('shortcut defaults', () => {
});
});
it('registers manual SQL AI completion as a query editor shortcut', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.triggerSqlAiCompletion).toEqual({
mac: { combo: 'Alt+\\', enabled: true },
windows: { combo: 'Alt+\\', enabled: true },
});
expect(SHORTCUT_ACTION_META.triggerSqlAiCompletion).toMatchObject({
label: '触发 SQL AI 自动补全',
scope: 'queryEditor',
allowInEditable: true,
});
});
it('registers query results panel toggle as a query editor shortcut', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.toggleQueryResultsPanel).toEqual({
mac: { combo: 'Meta+Shift+M', enabled: true },
@@ -581,6 +643,13 @@ describe('comboToMonacoKeyBinding', () => {
});
});
it('maps Alt+\\ (manual AI completion)', () => {
expect(comboToMonacoKeyBinding('Alt+\\', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.Alt,
keyCode: mockKeyCode.Oem5,
});
});
it('returns null for empty combo', () => {
expect(comboToMonacoKeyBinding('', mockKeyMod, mockKeyCode)).toBeNull();
});

View File

@@ -8,6 +8,7 @@ export type ShortcutAction =
| 'duplicateCurrentLine'
| 'saveQuery'
| 'formatSql'
| 'triggerSqlAiCompletion'
| 'toggleQueryResultsPanel'
| 'sendAIChatMessage'
| 'focusSidebarSearch'
@@ -108,6 +109,7 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'duplicateCurrentLine',
'saveQuery',
'formatSql',
'triggerSqlAiCompletion',
'toggleQueryResultsPanel',
'sendAIChatMessage',
'focusSidebarSearch',
@@ -174,6 +176,12 @@ const SHORTCUT_ACTION_META_DEFINITIONS: Record<ShortcutAction, ShortcutActionMet
scope: 'queryEditor',
allowInEditable: true,
},
triggerSqlAiCompletion: {
labelKey: 'app.shortcuts.action.triggerSqlAiCompletion.label',
descriptionKey: 'app.shortcuts.action.triggerSqlAiCompletion.description',
scope: 'queryEditor',
allowInEditable: true,
},
toggleQueryResultsPanel: {
labelKey: 'app.shortcuts.action.toggleQueryResultsPanel.label',
descriptionKey: 'app.shortcuts.action.toggleQueryResultsPanel.description',
@@ -282,6 +290,10 @@ export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
mac: { combo: 'Alt+Shift+F', enabled: true },
windows: { combo: 'Alt+Shift+F', enabled: true },
},
triggerSqlAiCompletion: {
mac: { combo: 'Alt+\\', enabled: true },
windows: { combo: 'Alt+\\', enabled: true },
},
toggleQueryResultsPanel: {
mac: { combo: 'Meta+Shift+M', enabled: true },
windows: { combo: 'Ctrl+Shift+M', enabled: true },
@@ -406,6 +418,54 @@ const normalizeKeyboardKey = (key: string): string => {
return token.length > 1 ? token[0].toUpperCase() + token.slice(1) : token;
};
const KEYBOARD_CODE_ALIASES: Record<string, string> = {
Backslash: '\\',
IntlBackslash: '\\',
Slash: '/',
Comma: ',',
Period: '.',
Semicolon: ';',
Quote: "'",
BracketLeft: '[',
BracketRight: ']',
Minus: '-',
Equal: '=',
Backquote: '`',
};
const KEY_CODE_ALIASES: Record<number, string> = {
186: ';',
187: '=',
188: ',',
189: '-',
190: '.',
191: '/',
192: '`',
219: '[',
220: '\\',
226: '\\',
221: ']',
222: "'",
};
const normalizeKeyboardEventCode = (
event: (KeyboardEvent | ReactKeyboardEvent) & {
code?: string;
keyCode?: number;
which?: number;
nativeEvent?: { code?: string; keyCode?: number; which?: number };
},
): string => {
const code = String(event.code || event.nativeEvent?.code || '').trim();
if (code) {
const alias = KEYBOARD_CODE_ALIASES[code];
if (alias) return alias;
}
const keyCode = Number(event.keyCode ?? event.nativeEvent?.keyCode ?? event.which ?? event.nativeEvent?.which ?? 0);
return KEY_CODE_ALIASES[keyCode] || '';
};
let globalImeCompositionActive = false;
export const setGlobalImeCompositionActive = (active: boolean): void => {
@@ -490,15 +550,16 @@ export const isImeComposingKeyEvent = (
const key = String(event.key || '').trim();
const keyCode = Number(event.keyCode ?? nativeEvent?.keyCode ?? 0);
const which = Number(event.which ?? nativeEvent?.which ?? 0);
const hasModifier = Boolean(event.ctrlKey || event.metaKey || event.altKey);
// Primary IME indicators — reliable across all browsers/WebViews.
if (
globalImeCompositionActive
|| event.isComposing
|| nativeEvent?.isComposing
|| key === 'Process'
|| keyCode === 229
|| which === 229
|| (key === 'Process' && !hasModifier)
) {
return true;
}
@@ -512,7 +573,6 @@ export const isImeComposingKeyEvent = (
// even when the user is pressing a shortcut (e.g. Cmd+E) while a CJK input
// method is simply *enabled* (not actively composing). Blocking modifier-
// key combos would break all window-level shortcuts for CJK users.
const hasModifier = Boolean(event.ctrlKey || event.metaKey || event.altKey);
if (!hasModifier && isMonacoImeInputTarget(event.target)) {
return true;
}
@@ -520,29 +580,70 @@ export const isImeComposingKeyEvent = (
return false;
};
export const eventToShortcut = (event: KeyboardEvent | ReactKeyboardEvent): string => {
if (isImeComposingKeyEvent(event)) {
return '';
}
const key = normalizeKeyboardKey(event.key);
if (!key || MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number])) {
return '';
}
const resolveShortcutModifiersFromEvent = (event: KeyboardEvent | ReactKeyboardEvent): string[] => {
const modifiers: string[] = [];
if (event.ctrlKey) modifiers.push('Ctrl');
if (event.metaKey) modifiers.push('Meta');
if (event.altKey) modifiers.push('Alt');
if (event.shiftKey) modifiers.push('Shift');
return modifiers;
};
const normalizeShortcutCandidate = (modifiers: string[], key: string): string => {
if (!key || MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number])) {
return '';
}
return normalizeShortcutCombo([...modifiers, key].join('+'));
};
const isUsableShortcutKey = (key: string): boolean => (
Boolean(key)
&& !MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number])
&& key !== 'Process'
&& key !== 'Unidentified'
&& key !== 'Dead'
);
const eventToShortcutCandidates = (event: KeyboardEvent | ReactKeyboardEvent): string[] => {
if (isImeComposingKeyEvent(event)) {
return [];
}
const modifiers = resolveShortcutModifiersFromEvent(event);
const candidates: string[] = [];
const pushCandidate = (key: string) => {
const candidate = normalizeShortcutCandidate(modifiers, key);
if (candidate && !candidates.includes(candidate)) {
candidates.push(candidate);
}
};
const key = normalizeKeyboardKey(event.key);
if (isUsableShortcutKey(key)) {
pushCandidate(key);
}
const codeKey = normalizeKeyboardEventCode(event);
if (
codeKey
&& (
!isUsableShortcutKey(key)
|| key.length !== 1
)
) {
pushCandidate(codeKey);
}
return candidates;
};
export const eventToShortcut = (event: KeyboardEvent | ReactKeyboardEvent): string => {
return eventToShortcutCandidates(event)[0] || '';
};
export const isShortcutMatch = (event: KeyboardEvent | ReactKeyboardEvent, combo: string): boolean => {
const expected = normalizeShortcutCombo(combo);
if (!expected) return false;
const actual = eventToShortcut(event);
return actual === expected;
return eventToShortcutCandidates(event).includes(expected);
};
export const getShortcutPlatform = (isMacRuntime?: boolean): ShortcutPlatform => (