diff --git a/docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md b/docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md
new file mode 100644
index 0000000..ead34b9
--- /dev/null
+++ b/docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md
@@ -0,0 +1,73 @@
+# 需求进度追踪 - AI聊天发送快捷键
+
+## 1. 需求摘要
+- 需求名称:AI 聊天发送快捷键
+- 提出日期:2026-04-28
+- 负责人:Claude Code
+- 目标:将 AI 聊天发送快捷键纳入工具中心快捷键管理,支持录制自定义 Enter 相关组合键,降低输入法 Enter 上屏时误发送的风险。
+- 非目标:不调整后端 AI 服务配置,不改发送按钮行为,不把 AI 发送快捷键放在 AI 设置弹窗的独立入口。
+
+## 2. 范围与验收
+- 范围:工具中心快捷键管理、AI 聊天输入框、本地前端偏好持久化。
+- 验收标准:工具中心出现“AI 聊天发送”快捷键;默认 Enter 发送;可录制 Enter / Cmd+Enter / Ctrl+Enter / Alt+Enter 等 Enter 相关组合;普通字符键不可录制为 AI 发送;Shift+Enter 始终换行;输入法 composing 状态不发送;刷新后快捷键保持;AI 设置弹窗不再出现独立“聊天输入”快捷键入口。
+- 依赖与约束:沿用 Zustand `lite-db-storage` 中的 `shortcutOptions` 持久化;保持现有 AI 后端接口不变。
+
+## 3. 里程碑与进度
+- [x] 阶段 1(需求澄清):确认输入法 Enter 上屏导致误发送,需要支持录制自定义快捷键,并复用工具中心快捷键体系。
+- [x] 阶段 2(影响分析):影响工具中心快捷键配置、AIChatPanel、AIChatInput、store 和相关测试。
+- [x] 阶段 3(方案设计):采用共享 `shortcutOptions` action,AI 输入框局部消费,不走全局快捷键执行器。
+- [x] 阶段 4(实施计划):计划已按用户反馈调整为工具中心统一方案。
+- [x] 阶段 5(实现与自检):目标红灯测试已补充,新方案核心实现已完成。
+- [x] 阶段 6(评审与交付):已完成代码审查反馈修复、目标测试、全量测试、构建、diff 检查和浏览器手工验证。
+- [ ] 阶段 7(发布与观察):发布后观察用户输入法场景反馈。
+
+## 4. 变更清单
+- 已完成:新增工具中心 AI 发送 action 目标测试;实现 Enter 默认快捷键、Enter 组合录制规则、AI 输入框按 `shortcutOptions` 判定发送;移除 AI 设置独立入口;修复刷新后录制值被启动配置刷新覆盖的问题;限制 AI 发送快捷键只能录制 0 或 1 个修饰键的 Enter 组合;消费 AI 发送快捷键后阻止事件继续冒泡;更新 store、工具函数和输入框提示测试。
+- 进行中:无。
+- 待处理:发布后观察输入法场景反馈。
+
+## 5. 风险与阻塞
+- 风险:默认 Enter 发送在少数未标记 composing 的输入法中仍可能误发。
+- 阻塞:无。
+- 缓解措施:用户可在工具中心录制 Cmd+Enter / Ctrl+Enter / Alt+Enter,普通 Enter 不再触发发送;AI 发送录制限制为 Enter 相关组合并保留 Shift+Enter 换行;输入法 composing 状态始终不发送。
+
+## 6. 决策记录
+- 决策 1:AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。
+- 决策 2:`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。
+- 决策 3:AI 发送快捷键允许默认无修饰键 Enter,但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。
+- 决策 4:输入法 composing 状态始终不发送。
+- 决策 5:AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter,拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。
+- 决策 6:AI 输入框命中发送快捷键后同时执行 `preventDefault` 和 `stopPropagation`,避免事件继续冒泡到全局快捷键处理器。
+
+## 7. 验证记录
+- 验证项:初版两档下拉方案红灯测试。
+- 结果:已确认旧实现失败。
+- 证据:`aiChatSendShortcut.test.ts` 缺模块失败;`store.test.ts` 新增字段缺失失败;`AIChatInput.notice.test.tsx` placeholder 仍为 Enter 失败。
+- 验证项:工具中心统一方案红灯测试。
+- 结果:已确认旧实现失败。
+- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts` 显示缺少 `sendAIChatMessage` action、`canRecordShortcutForAction` 和自定义 binding 判定失败;`src/store.test.ts` 显示 `shortcutOptions.sendAIChatMessage` 缺失;`src/components/ai/AIChatInput.notice.test.tsx` 显示 placeholder 未渲染 `Meta+Enter 发送`。
+- 验证项:工具中心统一方案目标绿灯测试。
+- 结果:已通过。
+- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts`(6 passed)、`src/components/ai/AIChatInput.notice.test.tsx`(2 passed)、`src/store.test.ts`(10 passed)。
+- 验证项:代码审查反馈红灯测试。
+- 结果:已确认旧实现失败。
+- 证据:多修饰键 Enter 组合被误放行、缺少 `consumeAIChatSendShortcutOnKeyDown`、脏持久化 `sendAIChatMessage: A` 未回退到 Enter。
+- 验证项:代码审查反馈修复后目标测试。
+- 结果:已通过。
+- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts src/components/ai/AIChatInput.notice.test.tsx src/store.test.ts`(3 files passed,22 tests passed)。
+- 验证项:浏览器手工验证。
+- 结果:已通过。
+- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持;AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送,Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送;Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。
+- 验证项:前端全量测试。
+- 结果:已通过。
+- 证据:`npm --prefix frontend test -- --run`(88 files passed,421 tests passed)。
+- 验证项:diff 空白检查。
+- 结果:已通过。
+- 证据:`git diff --check` 无输出。
+- 验证项:生产构建。
+- 结果:已通过。
+- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。
+
+## 8. 下一步
+- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。
+- 负责人:Claude Code
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5ff7126..72a9481 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -18,7 +18,7 @@ import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModa
import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModal';
import { DEFAULT_APPEARANCE, useStore } from './store';
import { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types';
-import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from './utils/appearance';
+import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay';
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics';
@@ -62,9 +62,9 @@ import {
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
ShortcutAction,
+ canRecordShortcutForAction,
eventToShortcut,
getShortcutDisplay,
- hasModifierKey,
isEditableElement,
isShortcutMatch,
normalizeShortcutCombo,
@@ -836,7 +836,6 @@ function App() {
fontSize: isSidebarCompact ? 13 : 14,
}), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, isSidebarCompact, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]);
const disableLocalBackdropFilter = isMacLikePlatform();
- const textInputSafeBackdropFilter = resolveTextInputSafeBackdropFilter(blurFilter, disableLocalBackdropFilter);
const overlayTheme = useMemo(
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
[darkMode, disableLocalBackdropFilter],
@@ -2388,11 +2387,15 @@ function App() {
useEffect(() => {
const handleGlobalShortcut = (event: KeyboardEvent) => {
const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => {
+ const meta = SHORTCUT_ACTION_META[action];
+ if (meta.scope && meta.scope !== 'global') {
+ return false;
+ }
const binding = shortcutOptions[action];
if (!binding?.enabled) {
return false;
}
- if (isEditableElement(event.target) && !SHORTCUT_ACTION_META[action].allowInEditable) {
+ if (isEditableElement(event.target) && !meta.allowInEditable) {
return false;
}
return isShortcutMatch(event, binding.combo);
@@ -2456,12 +2459,15 @@ function App() {
if (!combo) {
return;
}
- if (!hasModifierKey(combo)) {
- void message.warning('快捷键至少包含 Ctrl / Alt / Shift / Meta 之一');
- return;
- }
const normalizedCombo = normalizeShortcutCombo(combo);
+ if (!canRecordShortcutForAction(capturingShortcutAction, normalizedCombo)) {
+ const meta = SHORTCUT_ACTION_META[capturingShortcutAction];
+ void message.warning(meta.scope === 'aiComposer'
+ ? 'AI 聊天发送快捷键仅支持 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter,Shift+Enter 保留换行'
+ : '快捷键至少包含 Ctrl / Alt / Shift / Meta 之一');
+ return;
+ }
const conflictAction = SHORTCUT_ACTION_ORDER.find((action) => {
if (action === capturingShortcutAction) {
return false;
@@ -2566,8 +2572,8 @@ function App() {
background: 'transparent',
borderRadius: showLinuxResizeHandles ? 0 : 'var(--gonavi-border-radius)',
clipPath: showLinuxResizeHandles ? 'none' : 'inset(0 round var(--gonavi-border-radius))',
- backdropFilter: textInputSafeBackdropFilter,
- WebkitBackdropFilter: textInputSafeBackdropFilter,
+ backdropFilter: blurFilter,
+ WebkitBackdropFilter: blurFilter,
}}>
{/* Custom Title Bar */}
- 点击“录制”后按下快捷键。按 Esc 可取消录制。建议至少包含一个修饰键(Ctrl/Alt/Shift/Meta)。
+ 点击“录制”后按下快捷键。按 Esc 可取消录制。全局快捷键建议包含修饰键;AI 聊天发送仅支持 Enter 相关组合,Shift+Enter 保留换行。
{SHORTCUT_ACTION_ORDER.map((action) => {
diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx
index 43a140b..752f1ad 100644
--- a/frontend/src/components/AIChatPanel.tsx
+++ b/frontend/src/components/AIChatPanel.tsx
@@ -27,6 +27,7 @@ import {
} from '../utils/aiComposerNotice';
import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit';
import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool';
+import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
interface AIChatPanelProps {
width?: number;
@@ -256,6 +257,7 @@ export const AIChatPanel: React.FC
= ({
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const aiPanelVisible = useStore(state => state.aiPanelVisible);
+ const aiChatSendShortcutBinding = useStore(state => state.shortcutOptions.sendAIChatMessage);
const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => {
const state = useStore.getState();
@@ -1470,11 +1472,8 @@ SELECT * FROM users WHERE status = 1;
]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSend();
- }
- }, [handleSend]);
+ consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, e, handleSend);
+ }, [aiChatSendShortcutBinding, handleSend]);
const handleStop = useCallback(async () => {
try {
@@ -1705,6 +1704,7 @@ SELECT * FROM users WHERE status = 1;
activeProvider={activeProvider}
dynamicModels={dynamicModels}
loadingModels={loadingModels}
+ sendShortcutBinding={aiChatSendShortcutBinding}
composerNotice={composerNotice}
onModelChange={handleModelChange}
onFetchModels={fetchDynamicModels}
diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx
index 9a55cae..f73d4aa 100644
--- a/frontend/src/components/AISettingsModal.tsx
+++ b/frontend/src/components/AISettingsModal.tsx
@@ -20,7 +20,6 @@ import {
} from '../utils/aiSettingsPresetLayout';
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
-
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface AISettingsModalProps {
diff --git a/frontend/src/components/ai/AIChatInput.notice.test.tsx b/frontend/src/components/ai/AIChatInput.notice.test.tsx
index f8b75e6..43df44b 100644
--- a/frontend/src/components/ai/AIChatInput.notice.test.tsx
+++ b/frontend/src/components/ai/AIChatInput.notice.test.tsx
@@ -36,6 +36,7 @@ describe('AIChatInput notice layout', () => {
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
+ sendShortcutBinding={{ combo: 'Enter', enabled: true }}
composerNotice={{
tone: 'error',
title: '模型列表加载失败',
@@ -58,4 +59,35 @@ describe('AIChatInput notice layout', () => {
expect(inputIndex).toBeGreaterThanOrEqual(0);
expect(noticeIndex).toBeLessThan(inputIndex);
});
+
+ it('renders the selected send shortcut in the composer placeholder', () => {
+ const markup = renderToStaticMarkup(
+ {}}
+ draftImages={[]}
+ setDraftImages={() => {}}
+ sending={false}
+ onSend={() => {}}
+ onStop={() => {}}
+ handleKeyDown={() => {}}
+ activeConnName=""
+ activeContext={null}
+ activeProvider={{ model: '', models: [] }}
+ dynamicModels={[]}
+ loadingModels={false}
+ sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }}
+ composerNotice={null}
+ onModelChange={() => {}}
+ onFetchModels={() => {}}
+ textareaRef={React.createRef()}
+ darkMode={false}
+ textColor="#162033"
+ mutedColor="rgba(16,24,40,0.55)"
+ overlayTheme={buildOverlayWorkbenchTheme(false)}
+ />
+ );
+
+ expect(markup).toContain('Meta+Enter 发送');
+ });
});
diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx
index e497005..4f14f12 100644
--- a/frontend/src/components/ai/AIChatInput.tsx
+++ b/frontend/src/components/ai/AIChatInput.tsx
@@ -7,6 +7,8 @@ import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
+import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut';
+import type { ShortcutBinding } from '../../utils/shortcuts';
interface AIChatInputProps {
input: string;
@@ -22,6 +24,7 @@ interface AIChatInputProps {
activeProvider: any;
dynamicModels: string[];
loadingModels: boolean;
+ sendShortcutBinding: ShortcutBinding;
composerNotice?: AIComposerNotice | null;
onModelChange: (val: string) => void;
onFetchModels: () => void;
@@ -37,7 +40,7 @@ interface AIChatInputProps {
export const AIChatInput: React.FC = ({
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
- composerNotice,
+ sendShortcutBinding, composerNotice,
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
contextUsageChars, maxContextChars
}) => {
@@ -379,7 +382,7 @@ export const AIChatInput: React.FC = ({
}
}}
onKeyDown={handleKeyDown as any}
- placeholder="输入消息... (Enter 发送,Shift+Enter 换行,/ 快捷命令)"
+ placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding)},Shift+Enter 换行,/ 快捷命令)`}
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts
index 727b105..95cc405 100644
--- a/frontend/src/store.test.ts
+++ b/frontend/src/store.test.ts
@@ -255,4 +255,132 @@ describe('store appearance persistence', () => {
},
]);
});
+
+ it('defaults AI chat send shortcut to Enter in shared shortcut options', async () => {
+ const { useStore } = await importStore();
+
+ expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
+ combo: 'Enter',
+ enabled: true,
+ });
+ });
+
+ it('persists recorded AI chat send shortcut and restores it after reload', async () => {
+ const { useStore } = await importStore();
+
+ useStore.getState().updateShortcut('sendAIChatMessage', {
+ combo: 'Meta+Enter',
+ enabled: true,
+ });
+
+ const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
+ expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
+ combo: 'Meta+Enter',
+ enabled: true,
+ });
+
+ vi.resetModules();
+ const reloaded = await importStore();
+ expect(reloaded.useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
+ combo: 'Meta+Enter',
+ enabled: true,
+ });
+ });
+
+ it('falls back to Enter when persisted AI chat send shortcut is invalid', async () => {
+ storage.setItem('lite-db-storage', JSON.stringify({
+ state: {
+ shortcutOptions: {
+ sendAIChatMessage: {
+ combo: 'A',
+ enabled: true,
+ },
+ },
+ },
+ version: 8,
+ }));
+
+ const { useStore } = await importStore();
+
+ expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
+ combo: 'Enter',
+ enabled: true,
+ });
+ });
+
+ it('does not overwrite recorded AI chat send shortcut during startup config refresh', async () => {
+ const { useStore } = await importStore();
+ useStore.getState().updateShortcut('sendAIChatMessage', {
+ combo: 'Ctrl+Enter',
+ enabled: true,
+ });
+
+ useStore.getState().replaceConnections([]);
+
+ const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
+ expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
+ combo: 'Ctrl+Enter',
+ enabled: true,
+ });
+ });
+
+ it('keeps persisted AI chat send shortcut when startup refresh runs before shortcut hydration catches up', async () => {
+ const { useStore } = await importStore();
+ const shortcutOptions = useStore.getState().shortcutOptions;
+ storage.setItem('lite-db-storage', JSON.stringify({
+ state: {
+ shortcutOptions: {
+ ...shortcutOptions,
+ sendAIChatMessage: {
+ combo: 'Meta+Enter',
+ enabled: true,
+ },
+ },
+ },
+ version: 8,
+ }));
+ useStore.setState({
+ shortcutOptions: {
+ ...shortcutOptions,
+ sendAIChatMessage: {
+ combo: 'Enter',
+ enabled: true,
+ },
+ },
+ });
+
+ useStore.getState().replaceConnections([]);
+
+ const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
+ expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
+ combo: 'Meta+Enter',
+ enabled: true,
+ });
+ });
+
+ it('does not let a stale default shortcut state overwrite an explicitly recorded AI chat shortcut', async () => {
+ const { useStore } = await importStore();
+ const shortcutOptions = useStore.getState().shortcutOptions;
+
+ useStore.getState().updateShortcut('sendAIChatMessage', {
+ combo: 'Meta+Enter',
+ enabled: true,
+ });
+ useStore.setState({
+ shortcutOptions: {
+ ...shortcutOptions,
+ sendAIChatMessage: {
+ combo: 'Enter',
+ enabled: true,
+ },
+ },
+ });
+ useStore.getState().replaceGlobalProxy({});
+
+ const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
+ expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
+ combo: 'Meta+Enter',
+ enabled: true,
+ });
+ });
});
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index cd5a22e..b6a9587 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -61,6 +61,7 @@ const MAX_TIMEOUT_SECONDS = 3600;
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
const PERSIST_VERSION = 8;
+const PERSIST_STORAGE_KEY = "lite-db-storage";
const DEFAULT_CONNECTION_TYPE = "mysql";
const DEFAULT_JVM_PORT = 9010;
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
@@ -1156,6 +1157,46 @@ const unwrapPersistedAppState = (
return raw;
};
+let shortcutOptionsExplicitlySet = false;
+
+const readPersistedShortcutOptions = (): ShortcutOptions | null => {
+ if (typeof localStorage === "undefined") {
+ return null;
+ }
+ try {
+ const payload = localStorage.getItem(PERSIST_STORAGE_KEY);
+ if (!payload) {
+ return null;
+ }
+ const state = unwrapPersistedAppState(JSON.parse(payload));
+ if (state.shortcutOptions === undefined) {
+ return null;
+ }
+ return sanitizeShortcutOptions(state.shortcutOptions);
+ } catch {
+ return null;
+ }
+};
+
+const resolveShortcutOptionsForPersistence = (
+ shortcutOptions: ShortcutOptions,
+): ShortcutOptions => {
+ const safeOptions = sanitizeShortcutOptions(shortcutOptions);
+ if (shortcutOptionsExplicitlySet) {
+ return safeOptions;
+ }
+ return readPersistedShortcutOptions() ?? safeOptions;
+};
+
+const runWithExplicitShortcutPersistence = (callback: () => void): void => {
+ shortcutOptionsExplicitlySet = true;
+ try {
+ callback();
+ } finally {
+ shortcutOptionsExplicitlySet = false;
+ }
+};
+
// --- AI 会话文件持久化辅助函数 ---
/** 每个 session 独立防抖定时器(2秒) */
@@ -1294,7 +1335,10 @@ export const useStore = create()(
})),
})),
replaceConnections: (connections) =>
- set({ connections: sanitizeConnections(connections) }),
+ set((state) => ({
+ connections: sanitizeConnections(connections),
+ shortcutOptions: readPersistedShortcutOptions() ?? state.shortcutOptions,
+ })),
addConnectionTag: (tag) =>
set((state) => ({ connectionTags: [...state.connectionTags, tag] })),
@@ -1606,31 +1650,38 @@ export const useStore = create()(
globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }),
})),
replaceGlobalProxy: (proxy) =>
- set({
+ set((state) => ({
globalProxy: sanitizeGlobalProxy({
...DEFAULT_GLOBAL_PROXY,
...proxy,
}),
- }),
+ shortcutOptions: readPersistedShortcutOptions() ?? state.shortcutOptions,
+ })),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
setQueryOptions: (options) =>
set((state) => ({
queryOptions: { ...state.queryOptions, ...options },
})),
- updateShortcut: (action, binding) =>
- set((state) => ({
- shortcutOptions: {
- ...state.shortcutOptions,
- [action]: {
- ...state.shortcutOptions[action],
- ...binding,
+ updateShortcut: (action, binding) => {
+ runWithExplicitShortcutPersistence(() => {
+ set((state) => ({
+ shortcutOptions: {
+ ...state.shortcutOptions,
+ [action]: {
+ ...state.shortcutOptions[action],
+ ...binding,
+ },
},
- },
- })),
- resetShortcutOptions: () =>
- set({
- shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
- }),
+ }));
+ });
+ },
+ resetShortcutOptions: () => {
+ runWithExplicitShortcutPersistence(() => {
+ set({
+ shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
+ });
+ });
+ },
addSqlLog: (log) =>
set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
@@ -1931,7 +1982,7 @@ export const useStore = create()(
})),
}),
{
- name: "lite-db-storage", // name of the item in the storage (must be unique)
+ name: PERSIST_STORAGE_KEY, // name of the item in the storage (must be unique)
version: PERSIST_VERSION,
migrate: (persistedState: unknown, version: number) => {
const state = unwrapPersistedAppState(
@@ -2054,7 +2105,7 @@ export const useStore = create()(
: toPersistedGlobalProxy(state.globalProxy),
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
- shortcutOptions: state.shortcutOptions,
+ shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions),
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,
diff --git a/frontend/src/utils/aiChatSendShortcut.test.ts b/frontend/src/utils/aiChatSendShortcut.test.ts
new file mode 100644
index 0000000..0ae56ec
--- /dev/null
+++ b/frontend/src/utils/aiChatSendShortcut.test.ts
@@ -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);
+ });
+});
diff --git a/frontend/src/utils/aiChatSendShortcut.ts b/frontend/src/utils/aiChatSendShortcut.ts
new file mode 100644
index 0000000..d2ebcab
--- /dev/null
+++ b/frontend/src/utils/aiChatSendShortcut.ts
@@ -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;
+};
diff --git a/frontend/src/utils/inputAutoCap.test.ts b/frontend/src/utils/inputAutoCap.test.ts
index 1a50234..e7dff93 100644
--- a/frontend/src/utils/inputAutoCap.test.ts
+++ b/frontend/src/utils/inputAutoCap.test.ts
@@ -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 = {};
const textareaAttributes: Record = {};
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');
});
});
diff --git a/frontend/src/utils/inputAutoCap.ts b/frontend/src/utils/inputAutoCap.ts
index 450cafa..589f0ea 100644
--- a/frontend/src/utils/inputAutoCap.ts
+++ b/frontend/src/utils/inputAutoCap.ts
@@ -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');
};
diff --git a/frontend/src/utils/shortcuts.ts b/frontend/src/utils/shortcuts.ts
index a768d32..eb34234 100644
--- a/frontend/src/utils/shortcuts.ts
+++ b/frontend/src/utils/shortcuts.ts
@@ -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 = {
export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'runQuery',
+ 'sendAIChatMessage',
'focusSidebarSearch',
'newQueryTab',
'toggleLogPanel',
@@ -86,6 +92,15 @@ export const SHORTCUT_ACTION_META: Record =
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 =
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;
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,
};
});