mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-07 15:09:34 +08:00
✨ feat(ai): 支持录制聊天发送快捷键
- 工具中心新增 AI 聊天发送快捷键,默认 Enter 并支持 Ctrl/Cmd/Alt+Enter - AI 输入框按录制绑定发送,保留 Shift+Enter 换行和输入法 composing 保护 - 修复 shortcutOptions 启动刷新覆盖录制值的问题,并校验脏持久化快捷键 - 补充快捷键、输入框提示和持久化回归测试 - 撤回 macOS Caps Lock 浮层无效前端规避,恢复输入控件 no-auto-cap 属性 - 新增需求进度追踪文档记录验证结果
This commit is contained in:
89
frontend/src/utils/aiChatSendShortcut.test.ts
Normal file
89
frontend/src/utils/aiChatSendShortcut.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
frontend/src/utils/aiChatSendShortcut.ts
Normal file
50
frontend/src/utils/aiChatSendShortcut.ts
Normal 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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user