feat(ai): 支持录制聊天发送快捷键

- 工具中心新增 AI 聊天发送快捷键,默认 Enter 并支持 Ctrl/Cmd/Alt+Enter
- AI 输入框按录制绑定发送,保留 Shift+Enter 换行和输入法 composing 保护
- 修复 shortcutOptions 启动刷新覆盖录制值的问题,并校验脏持久化快捷键
- 补充快捷键、输入框提示和持久化回归测试
- 撤回 macOS Caps Lock 浮层无效前端规避,恢复输入控件 no-auto-cap 属性
- 新增需求进度追踪文档记录验证结果
This commit is contained in:
Syngnat
2026-04-28 18:12:42 +08:00
parent 56eaca9081
commit 5f7578c5ea
13 changed files with 525 additions and 47 deletions

View File

@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';
import {
canRecordShortcutForAction,
DEFAULT_SHORTCUT_OPTIONS,
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
type ShortcutBinding,
} from './shortcuts';
import {
consumeAIChatSendShortcutOnKeyDown,
getAIChatSendShortcutLabel,
shouldSendAIChatOnKeyDown,
} from './aiChatSendShortcut';
const binding = (combo: string, enabled = true): ShortcutBinding => ({ combo, enabled });
describe('aiChatSendShortcut', () => {
it('registers AI chat send in the shared shortcut center with Enter default', () => {
expect(SHORTCUT_ACTION_ORDER).toContain('sendAIChatMessage');
expect(DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage).toEqual({ combo: 'Enter', enabled: true });
expect(SHORTCUT_ACTION_META.sendAIChatMessage).toMatchObject({
label: 'AI 聊天发送',
allowInEditable: true,
allowWithoutModifier: true,
scope: 'aiComposer',
requiredKey: 'Enter',
disallowShift: true,
});
});
it('allows recording only single-modifier Enter-based AI send shortcuts', () => {
expect(canRecordShortcutForAction('sendAIChatMessage', 'Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Meta+Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Alt+Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'A')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Shift+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Shift+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Alt+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Meta+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Meta+Alt+Enter')).toBe(false);
});
it('keeps modifier requirements for global shortcuts', () => {
expect(canRecordShortcutForAction('runQuery', 'Enter')).toBe(false);
expect(canRecordShortcutForAction('runQuery', 'Ctrl+Enter')).toBe(true);
});
it('sends on the configured Enter shortcut but never during composition or Shift+Enter', () => {
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter' })).toBe(true);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', shiftKey: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', isComposing: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', nativeEvent: { isComposing: true } })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'a' })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter', false), { key: 'Enter' })).toBe(false);
});
it('matches recorded Cmd or Ctrl Enter shortcuts', () => {
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter' })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter', metaKey: true })).toBe(true);
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter', ctrlKey: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', ctrlKey: true })).toBe(true);
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', metaKey: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', ctrlKey: true, isComposing: true })).toBe(false);
});
it('does not allow Shift to become an AI send shortcut even if a stale binding exists', () => {
expect(shouldSendAIChatOnKeyDown(binding('Shift+Enter'), { key: 'Enter', shiftKey: true })).toBe(false);
expect(getAIChatSendShortcutLabel(binding('Meta+Enter'))).toBe('Meta+Enter 发送');
expect(getAIChatSendShortcutLabel(binding('Enter', false))).toBe('快捷键发送已关闭');
});
it('stops propagation after consuming the configured AI send shortcut', () => {
const event = {
key: 'Enter',
metaKey: true,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
const onSend = vi.fn();
expect(consumeAIChatSendShortcutOnKeyDown(binding('Meta+Enter'), event, onSend)).toBe(true);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(event.stopPropagation).toHaveBeenCalledTimes(1);
expect(onSend).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,50 @@
import { DEFAULT_SHORTCUT_OPTIONS, getShortcutDisplay, isShortcutMatch, type ShortcutBinding } from './shortcuts';
export interface AIChatSendShortcutKeyEventLike {
key?: string;
shiftKey?: boolean;
metaKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
isComposing?: boolean;
nativeEvent?: {
isComposing?: boolean;
};
preventDefault?: () => void;
stopPropagation?: () => void;
}
export const getAIChatSendShortcutLabel = (binding: ShortcutBinding | undefined): string => {
if (binding?.enabled === false) {
return '快捷键发送已关闭';
}
const combo = binding?.combo || DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage.combo;
return `${getShortcutDisplay(combo)} 发送`;
};
export const shouldSendAIChatOnKeyDown = (
binding: ShortcutBinding | undefined,
event: AIChatSendShortcutKeyEventLike,
): boolean => {
if (!binding?.enabled) {
return false;
}
if (event.shiftKey || event.isComposing || event.nativeEvent?.isComposing) {
return false;
}
return isShortcutMatch(event as KeyboardEvent, binding.combo);
};
export const consumeAIChatSendShortcutOnKeyDown = (
binding: ShortcutBinding | undefined,
event: AIChatSendShortcutKeyEventLike,
onSend: () => void,
): boolean => {
if (!shouldSendAIChatOnKeyDown(binding, event)) {
return false;
}
event.preventDefault?.();
event.stopPropagation?.();
onSend();
return true;
};

View File

@@ -3,14 +3,15 @@ import { describe, expect, it } from 'vitest';
import { applyNoAutoCapAttributes, applyNoAutoCapAttributesWithin, noAutoCapInputProps } from './inputAutoCap';
describe('inputAutoCap', () => {
it('exports input props that disable correction without forcing native capitalization state', () => {
it('exports input props that disable auto capitalization and correction', () => {
expect(noAutoCapInputProps).toEqual({
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
});
});
it('applies correction attributes to inputs and textareas without autocapitalize', () => {
it('applies no-auto-cap attributes to inputs and textareas', () => {
const inputAttributes: Record<string, string> = {};
const textareaAttributes: Record<string, string> = {};
const input = {
@@ -29,10 +30,10 @@ describe('inputAutoCap', () => {
applyNoAutoCapAttributes(input);
applyNoAutoCapAttributes(textarea);
expect(inputAttributes.autocapitalize).toBeUndefined();
expect(inputAttributes.autocapitalize).toBe('none');
expect(inputAttributes.autocorrect).toBe('off');
expect(inputAttributes.spellcheck).toBe('false');
expect(textareaAttributes.autocapitalize).toBeUndefined();
expect(textareaAttributes.autocapitalize).toBe('none');
expect(textareaAttributes.autocorrect).toBe('off');
expect(textareaAttributes.spellcheck).toBe('false');
});
@@ -61,9 +62,9 @@ describe('inputAutoCap', () => {
applyNoAutoCapAttributesWithin(root);
expect(inputAttributes.autocapitalize).toBeUndefined();
expect(inputAttributes.autocapitalize).toBe('none');
expect(inputAttributes.autocorrect).toBe('off');
expect(textareaAttributes.autocapitalize).toBeUndefined();
expect(textareaAttributes.autocapitalize).toBe('none');
expect(textareaAttributes.autocorrect).toBe('off');
});
});

View File

@@ -1,4 +1,5 @@
export const noAutoCapInputProps = {
autoCapitalize: 'none' as const,
autoCorrect: 'off' as const,
spellCheck: false,
};
@@ -9,9 +10,7 @@ export const applyNoAutoCapAttributes = (element: Element) => {
return;
}
if (typeof element.removeAttribute === 'function') {
element.removeAttribute('autocapitalize');
}
element.setAttribute('autocapitalize', 'none');
element.setAttribute('autocorrect', 'off');
element.setAttribute('spellcheck', 'false');
};

View File

@@ -2,6 +2,7 @@ import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
export type ShortcutAction =
| 'runQuery'
| 'sendAIChatMessage'
| 'focusSidebarSearch'
| 'newQueryTab'
| 'toggleLogPanel'
@@ -20,6 +21,10 @@ export interface ShortcutActionMeta {
label: string;
description: string;
allowInEditable?: boolean;
allowWithoutModifier?: boolean;
scope?: 'global' | 'aiComposer';
requiredKey?: string;
disallowShift?: boolean;
platformOnly?: 'mac';
}
@@ -73,6 +78,7 @@ const KEY_ALIASES: Record<string, string> = {
export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'runQuery',
'sendAIChatMessage',
'focusSidebarSearch',
'newQueryTab',
'toggleLogPanel',
@@ -86,6 +92,15 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
label: '执行 SQL',
description: '在当前查询页执行 SQL',
},
sendAIChatMessage: {
label: 'AI 聊天发送',
description: '在 AI 输入框中发送当前消息Shift+Enter 始终换行',
allowInEditable: true,
allowWithoutModifier: true,
scope: 'aiComposer',
requiredKey: 'Enter',
disallowShift: true,
},
focusSidebarSearch: {
label: '聚焦侧边栏搜索',
description: '定位到左侧连接树搜索框',
@@ -117,6 +132,7 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
runQuery: { combo: 'Ctrl+Shift+R', enabled: true },
sendAIChatMessage: { combo: 'Enter', enabled: true },
focusSidebarSearch: { combo: 'Ctrl+F', enabled: true },
newQueryTab: { combo: 'Ctrl+Shift+N', enabled: true },
toggleLogPanel: { combo: 'Ctrl+Shift+L', enabled: true },
@@ -213,6 +229,37 @@ export const hasModifierKey = (combo: string): boolean => {
return normalized.split('+').some(part => MODIFIER_SET.has(part as typeof MODIFIER_ORDER[number]));
};
const getShortcutKeyToken = (combo: string): string => {
const parts = normalizeShortcutCombo(combo).split('+').filter(Boolean);
const key = parts[parts.length - 1] || '';
return MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number]) ? '' : key;
};
const getShortcutModifierTokens = (combo: string): string[] => (
normalizeShortcutCombo(combo)
.split('+')
.filter(part => MODIFIER_SET.has(part as typeof MODIFIER_ORDER[number]))
);
export const canRecordShortcutForAction = (action: ShortcutAction, combo: string): boolean => {
const normalized = normalizeShortcutCombo(combo);
if (!normalized || !getShortcutKeyToken(normalized)) {
return false;
}
const meta = SHORTCUT_ACTION_META[action];
if (meta.requiredKey && getShortcutKeyToken(normalized) !== normalizeShortcutCombo(meta.requiredKey)) {
return false;
}
if (meta.disallowShift && normalized.split('+').includes('Shift')) {
return false;
}
if (meta.allowWithoutModifier) {
return getShortcutModifierTokens(normalized).length <= 1;
}
return hasModifierKey(normalized);
};
export const cloneShortcutOptions = (value: ShortcutOptions): ShortcutOptions => {
return SHORTCUT_ACTION_ORDER.reduce((acc, action) => {
acc[action] = {
@@ -235,7 +282,7 @@ export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => {
const binding = actionRaw as Record<string, unknown>;
const combo = normalizeShortcutCombo(String(binding.combo || defaults[action].combo));
defaults[action] = {
combo: combo || defaults[action].combo,
combo: combo && canRecordShortcutForAction(action, combo) ? combo : defaults[action].combo,
enabled: binding.enabled === false ? false : true,
};
});