From 5f7578c5eaa5d52875b0ca4819b21eb84fff7759 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 28 Apr 2026 18:12:42 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=BD=95=E5=88=B6=E8=81=8A=E5=A4=A9=E5=8F=91=E9=80=81=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工具中心新增 AI 聊天发送快捷键,默认 Enter 并支持 Ctrl/Cmd/Alt+Enter - AI 输入框按录制绑定发送,保留 Shift+Enter 换行和输入法 composing 保护 - 修复 shortcutOptions 启动刷新覆盖录制值的问题,并校验脏持久化快捷键 - 补充快捷键、输入框提示和持久化回归测试 - 撤回 macOS Caps Lock 浮层无效前端规避,恢复输入控件 no-auto-cap 属性 - 新增需求进度追踪文档记录验证结果 --- .../需求进度追踪-AI聊天发送快捷键-20260428.md | 73 ++++++++++ frontend/src/App.tsx | 28 ++-- frontend/src/components/AIChatPanel.tsx | 10 +- frontend/src/components/AISettingsModal.tsx | 1 - .../components/ai/AIChatInput.notice.test.tsx | 32 +++++ frontend/src/components/ai/AIChatInput.tsx | 7 +- frontend/src/store.test.ts | 128 ++++++++++++++++++ frontend/src/store.ts | 87 +++++++++--- frontend/src/utils/aiChatSendShortcut.test.ts | 89 ++++++++++++ frontend/src/utils/aiChatSendShortcut.ts | 50 +++++++ frontend/src/utils/inputAutoCap.test.ts | 13 +- frontend/src/utils/inputAutoCap.ts | 5 +- frontend/src/utils/shortcuts.ts | 49 ++++++- 13 files changed, 525 insertions(+), 47 deletions(-) create mode 100644 docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md create mode 100644 frontend/src/utils/aiChatSendShortcut.test.ts create mode 100644 frontend/src/utils/aiChatSendShortcut.ts 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, }; });