Compare commits

...

1 Commits

Author SHA1 Message Date
Soulter 2778edbf4b feat: add send shortcut configuration and localization support for chat input 2026-03-14 21:24:18 +08:00
6 changed files with 126 additions and 23 deletions
+17
View File
@@ -11,6 +11,7 @@
:currSessionId="currSessionId" :currSessionId="currSessionId"
:selectedProjectId="selectedProjectId" :selectedProjectId="selectedProjectId"
:transportMode="transportMode" :transportMode="transportMode"
:sendShortcut="sendShortcut"
:isDark="isDark" :isDark="isDark"
:chatboxMode="chatboxMode" :chatboxMode="chatboxMode"
:isMobile="isMobile" :isMobile="isMobile"
@@ -29,6 +30,7 @@
@editProject="showEditProjectDialog" @editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject" @deleteProject="handleDeleteProject"
@updateTransportMode="setTransportMode" @updateTransportMode="setTransportMode"
@updateSendShortcut="setSendShortcut"
/> />
<!-- 右侧聊天内容区域 --> <!-- 右侧聊天内容区域 -->
@@ -79,6 +81,7 @@
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage" @stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@@ -110,6 +113,7 @@
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage" @stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@@ -140,6 +144,7 @@
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage" @stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@@ -226,6 +231,8 @@ import { useToast } from '@/utils/toast';
interface Props { interface Props {
chatboxMode?: boolean; chatboxMode?: boolean;
} }
type SendShortcut = 'enter' | 'shift_enter';
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
chatboxMode: false chatboxMode: false
@@ -334,6 +341,12 @@ interface ReplyInfo {
const replyTo = ref<ReplyInfo | null>(null); const replyTo = ref<ReplyInfo | null>(null);
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark'); const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const sendShortcut = ref<SendShortcut>('shift_enter');
function setSendShortcut(mode: SendShortcut) {
sendShortcut.value = mode;
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
}
// 检测是否为手机端 // 检测是否为手机端
function checkMobile() { function checkMobile() {
@@ -725,6 +738,10 @@ watch(sessions, (newSessions) => {
}); });
onMounted(() => { onMounted(() => {
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
sendShortcut.value = storedShortcut;
}
checkMobile(); checkMobile();
window.addEventListener('resize', checkMobile); window.addEventListener('resize', checkMobile);
getSessions(); getSessions();
+26 -17
View File
@@ -173,6 +173,7 @@ interface Props {
currentSession?: Session | null; currentSession?: Session | null;
configId?: string | null; configId?: string | null;
replyTo?: ReplyInfo | null; replyTo?: ReplyInfo | null;
sendShortcut?: 'enter' | 'shift_enter';
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -180,7 +181,8 @@ const props = withDefaults(defineProps<Props>(), {
currentSession: null, currentSession: null,
configId: null, configId: null,
stagedFiles: () => [], stagedFiles: () => [],
replyTo: null replyTo: null,
sendShortcut: 'shift_enter'
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -253,9 +255,29 @@ watch(localPrompt, () => {
}); });
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
// Enter 插入换行(桌面和手机端均如此,发送通过右下角发送按鈕) const isEnter = e.key === 'Enter';
// Shift+Enter 发送(Ctrl+Enter / Cmd+Enter 也保留) if (!isEnter) {
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) { // Ctrl+B 录音
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
return;
}
const isSendHotkey =
e.ctrlKey ||
e.metaKey ||
(props.sendShortcut === 'enter' ? !e.shiftKey : e.shiftKey);
if (isSendHotkey) {
e.preventDefault(); e.preventDefault();
if (localPrompt.value.trim() === '/astr_live_dev') { if (localPrompt.value.trim() === '/astr_live_dev') {
emit('openLiveMode'); emit('openLiveMode');
@@ -267,19 +289,6 @@ function handleKeyDown(e: KeyboardEvent) {
} }
return; return;
} }
// Ctrl+B 录音
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
} }
function handleKeyUp(e: KeyboardEvent) { function handleKeyUp(e: KeyboardEvent) {
@@ -231,6 +231,50 @@
</v-card> </v-card>
</v-menu> </v-menu>
<!-- 发送快捷键分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: sendShortcutMenuProps }">
<v-list-item
v-bind="sendShortcutMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-keyboard-outline</v-icon>
</template>
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in sendShortcutOptions"
:key="opt.value"
:value="opt.value"
@click="handleSendShortcutChange(opt.value)"
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 全屏/退出全屏 --> <!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')"> <v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
<template v-slot:prepend> <template v-slot:prepend>
@@ -277,6 +321,7 @@ interface Props {
isMobile: boolean; isMobile: boolean;
mobileMenuOpen: boolean; mobileMenuOpen: boolean;
projects?: Project[]; projects?: Project[];
sendShortcut: 'enter' | 'shift_enter';
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -297,6 +342,7 @@ const emit = defineEmits<{
editProject: [project: Project]; editProject: [project: Project];
deleteProject: [projectId: string]; deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket']; updateTransportMode: [mode: 'sse' | 'websocket'];
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
@@ -357,6 +403,10 @@ const transportOptions = [
{ label: tm('transport.sse'), value: 'sse' as const }, { label: tm('transport.sse'), value: 'sse' as const },
{ label: tm('transport.websocket'), value: 'websocket' as const } { label: tm('transport.websocket'), value: 'websocket' as const }
]; ];
const sendShortcutOptions = [
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
];
// Language switcher // Language switcher
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher(); const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
@@ -376,6 +426,10 @@ const currentTransportLabel = computed(() => {
const found = transportOptions.find(opt => opt.value === props.transportMode); const found = transportOptions.find(opt => opt.value === props.transportMode);
return found?.label ?? ''; return found?.label ?? '';
}); });
const currentSendShortcutLabel = computed(() => {
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
return found?.label ?? '';
});
// 从 localStorage 读取侧边栏折叠状态 // 从 localStorage 读取侧边栏折叠状态
const savedCollapsedState = localStorage.getItem('sidebarCollapsed'); const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
@@ -403,6 +457,12 @@ function handleTransportModeChange(mode: string | null) {
emit('updateTransportMode', mode); emit('updateTransportMode', mode);
} }
} }
function handleSendShortcutChange(mode: string | null) {
if (mode === 'enter' || mode === 'shift_enter') {
emit('updateSendShortcut', mode);
}
}
</script> </script>
<style scoped> <style scoped>
@@ -71,10 +71,16 @@
"modes": { "modes": {
"darkMode": "Switch to Dark Mode", "darkMode": "Switch to Dark Mode",
"lightMode": "Switch to Light Mode" "lightMode": "Switch to Light Mode"
}, "shortcuts": { },
"shortcuts": {
"help": "Get Help", "help": "Get Help",
"voiceRecord": "Record Voice", "voiceRecord": "Record Voice",
"pasteImage": "Paste Image" "pasteImage": "Paste Image",
"sendKey": {
"title": "Send Shortcut",
"enterToSend": "Enter to send",
"shiftEnterToSend": "Shift+Enter to send"
}
}, },
"streaming": { "streaming": {
"enabled": "Streaming enabled", "enabled": "Streaming enabled",
@@ -75,7 +75,12 @@
"shortcuts": { "shortcuts": {
"help": "Справка", "help": "Справка",
"voiceRecord": "Запись голоса", "voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение" "pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
}, },
"streaming": { "streaming": {
"enabled": "Потоковый ответ включен", "enabled": "Потоковый ответ включен",
@@ -143,4 +148,4 @@
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз", "sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу" "createSessionFailed": "Ошибка создания сессии, обновите страницу"
} }
} }
@@ -71,10 +71,16 @@
"modes": { "modes": {
"darkMode": "切换到夜间模式", "darkMode": "切换到夜间模式",
"lightMode": "切换到日间模式" "lightMode": "切换到日间模式"
}, "shortcuts": { },
"shortcuts": {
"help": "获取帮助", "help": "获取帮助",
"voiceRecord": "录制语音", "voiceRecord": "录制语音",
"pasteImage": "粘贴图片" "pasteImage": "粘贴图片",
"sendKey": {
"title": "发送快捷键",
"enterToSend": "Enter 发送",
"shiftEnterToSend": "Shift+Enter 发送"
}
}, },
"streaming": { "streaming": {
"enabled": "流式响应已开启", "enabled": "流式响应已开启",