diff --git a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue index b4adc3c3b..eb750b68f 100644 --- a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue +++ b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue @@ -11,8 +11,10 @@ import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { BasicTextarea } from '@proj-airi/ui' +import { useLocalStorage } from '@vueuse/core' import { storeToRefs } from 'pinia' -import { computed, ref } from 'vue' +import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from 'reka-ui' +import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { widgetsTools } from '../stores/tools/builtin/widgets' @@ -32,6 +34,17 @@ const { t } = useI18n() const providersStore = useProvidersStore() const { activeModel, activeProvider } = storeToRefs(useConsciousnessStore()) const isComposing = ref(false) +const DOUBLE_ENTER_INTERVAL_MS = 300 +const TRAILING_NEWLINES_REGEX = /[\r\n]+$/ +const SEND_MODES = ['enter', 'ctrl-enter', 'double-enter'] as const +type SendMode = (typeof SEND_MODES)[number] +const sendMode = useLocalStorage('ui/chat/settings/send-mode', 'enter') +const lastEnterTime = ref(0) +const sendModeLabels = computed>(() => ({ + 'enter': t('stage.send-mode.enter'), + 'ctrl-enter': t('stage.send-mode.ctrl-enter'), + 'double-enter': t('stage.send-mode.double-enter'), +})) async function handleSend() { if (isComposing.value) { @@ -78,6 +91,46 @@ async function handleSend() { } } +function sendFromKeyboard() { + messageInput.value = messageInput.value.replace(TRAILING_NEWLINES_REGEX, '') + void handleSend() +} + +function handleMessageInputKeydown(event: KeyboardEvent) { + if (isComposing.value || event.key !== 'Enter') + return + + const hasControl = event.ctrlKey || event.metaKey + const hasShift = event.shiftKey + + switch (sendMode.value) { + case 'enter': + if (!hasShift && !hasControl) { + event.preventDefault() + sendFromKeyboard() + } + return + case 'ctrl-enter': + if (hasControl) { + event.preventDefault() + sendFromKeyboard() + } + return + case 'double-enter': + if (!hasShift && !hasControl) { + const now = Date.now() + if (now - lastEnterTime.value < DOUBLE_ENTER_INTERVAL_MS) { + event.preventDefault() + sendFromKeyboard() + lastEnterTime.value = 0 + } + else { + lastEnterTime.value = now + } + } + } +} + async function handleFilePaste(files: File[]) { for (const file of files) { if (file.type.startsWith('image/')) { @@ -112,6 +165,10 @@ onAfterMessageComposed(async () => { attachments.value = [] }) +watch(sendMode, () => { + lastEnterTime.value = 0 +}) + const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[]) @@ -124,17 +181,74 @@ const historyMessages = computed(() => messages.value as unknown as ChatHistoryI :streaming-message="streamingMessage" /> -
+
- -
-
+
+ + + + + + + +
+
+
+ {{ sendModeLabels[mode] }} + + + + +
messages.value as unknown as ChatHistoryI transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out" @compositionstart="isComposing = true" @compositionend="isComposing = false" - @keydown.enter.exact.prevent="handleSend" + @keydown="handleMessageInputKeydown" @paste-file="handleFilePaste" />
diff --git a/packages/i18n/src/locales/en/stage.yaml b/packages/i18n/src/locales/en/stage.yaml index 45060d416..4b833a8fc 100644 --- a/packages/i18n/src/locales/en/stage.yaml +++ b/packages/i18n/src/locales/en/stage.yaml @@ -6,6 +6,11 @@ chat: you: You reasoning: Reasoning message: Say something... +send-mode: + title: Send key + enter: Enter + ctrl-enter: Ctrl + Enter + double-enter: Double Enter operations: load-models: Load Models load-models-status: diff --git a/packages/i18n/src/locales/es/stage.yaml b/packages/i18n/src/locales/es/stage.yaml index 210767d47..e776bfa6e 100644 --- a/packages/i18n/src/locales/es/stage.yaml +++ b/packages/i18n/src/locales/es/stage.yaml @@ -6,6 +6,11 @@ chat: you: Tu reasoning: Razonamiento message: Dile algo... +send-mode: + title: Send key + enter: Enter + ctrl-enter: Ctrl + Enter + double-enter: Double Enter operations: load-models: Cargar Modelos load-models-status: diff --git a/packages/i18n/src/locales/fr/stage.yaml b/packages/i18n/src/locales/fr/stage.yaml index a1dfd1c88..aeee36eb1 100644 --- a/packages/i18n/src/locales/fr/stage.yaml +++ b/packages/i18n/src/locales/fr/stage.yaml @@ -6,6 +6,11 @@ chat: you: Vous reasoning: Raisonnement message: Dites quelque chose... +send-mode: + title: Send key + enter: Enter + ctrl-enter: Ctrl + Enter + double-enter: Double Enter operations: load-models: Charger les modèles load-models-status: diff --git a/packages/i18n/src/locales/ja/stage.yaml b/packages/i18n/src/locales/ja/stage.yaml index 040501d1c..b5a3e0f77 100644 --- a/packages/i18n/src/locales/ja/stage.yaml +++ b/packages/i18n/src/locales/ja/stage.yaml @@ -6,6 +6,11 @@ chat: you: あなた reasoning: 推論 message: 何か話しかけてみましょう… +send-mode: + title: Send key + enter: Enter + ctrl-enter: Ctrl + Enter + double-enter: Double Enter operations: load-models: モデルの読み込み load-models-status: diff --git a/packages/i18n/src/locales/ko/stage.yaml b/packages/i18n/src/locales/ko/stage.yaml index 57ac41f8b..4338617bc 100644 --- a/packages/i18n/src/locales/ko/stage.yaml +++ b/packages/i18n/src/locales/ko/stage.yaml @@ -6,6 +6,11 @@ chat: you: 당신 reasoning: 추론 message: 듣고 있어요... +send-mode: + title: Send key + enter: Enter + ctrl-enter: Ctrl + Enter + double-enter: Double Enter operations: load-models: 모델 불러오기 load-models-status: diff --git a/packages/i18n/src/locales/ru/stage.yaml b/packages/i18n/src/locales/ru/stage.yaml index 482824f74..72032eb96 100644 --- a/packages/i18n/src/locales/ru/stage.yaml +++ b/packages/i18n/src/locales/ru/stage.yaml @@ -6,6 +6,11 @@ chat: you: Ты reasoning: Причина message: Спроси что-нибудь +send-mode: + title: Send key + enter: Enter + ctrl-enter: Ctrl + Enter + double-enter: Double Enter operations: load-models: Загрузить модели load-models-status: diff --git a/packages/i18n/src/locales/vi/stage.yaml b/packages/i18n/src/locales/vi/stage.yaml index df1e9dfce..c9a192700 100644 --- a/packages/i18n/src/locales/vi/stage.yaml +++ b/packages/i18n/src/locales/vi/stage.yaml @@ -6,6 +6,11 @@ chat: you: Bạn reasoning: Suy nghĩ message: Nói gì đó... +send-mode: + title: Send key + enter: Enter + ctrl-enter: Ctrl + Enter + double-enter: Double Enter operations: load-models: Tải mô hình load-models-status: diff --git a/packages/i18n/src/locales/zh-Hans/stage.yaml b/packages/i18n/src/locales/zh-Hans/stage.yaml index e6c24451f..2972d6bb4 100644 --- a/packages/i18n/src/locales/zh-Hans/stage.yaml +++ b/packages/i18n/src/locales/zh-Hans/stage.yaml @@ -6,6 +6,11 @@ chat: you: 你 reasoning: 思考 message: 说点什么... +send-mode: + title: Send key + enter: Enter + ctrl-enter: Ctrl + Enter + double-enter: Double Enter operations: load-models: 加载模型 load-models-status: diff --git a/packages/i18n/src/locales/zh-Hant/stage.yaml b/packages/i18n/src/locales/zh-Hant/stage.yaml index 8442dc4da..3ce854e0a 100644 --- a/packages/i18n/src/locales/zh-Hant/stage.yaml +++ b/packages/i18n/src/locales/zh-Hant/stage.yaml @@ -6,6 +6,11 @@ chat: you: 你 reasoning: 推理 message: 說點什麼... +send-mode: + title: Send key + enter: Enter + ctrl-enter: Ctrl + Enter + double-enter: Double Enter operations: load-models: 載入模型 load-models-status: diff --git a/packages/stage-layouts/src/components/Widgets/ChatArea.vue b/packages/stage-layouts/src/components/Widgets/ChatArea.vue index bd794c65c..ee6671343 100644 --- a/packages/stage-layouts/src/components/Widgets/ChatArea.vue +++ b/packages/stage-layouts/src/components/Widgets/ChatArea.vue @@ -12,9 +12,9 @@ import { useHearingSpeechInputPipeline, useHearingStore } from '@proj-airi/stage import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings' import { BasicTextarea, FieldCombobox } from '@proj-airi/ui' -import { until } from '@vueuse/core' +import { until, useLocalStorage } from '@vueuse/core' import { storeToRefs } from 'pinia' -import { PopoverContent, PopoverRoot, PopoverTrigger } from 'reka-ui' +import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger, PopoverContent, PopoverRoot, PopoverTrigger } from 'reka-ui' import { computed, nextTick, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' @@ -24,6 +24,12 @@ const messageInput = ref('') const hearingPopoverOpen = ref(false) const isComposing = ref(false) const isListening = ref(false) // Transcription listening state (separate from microphone enabled) +const DOUBLE_ENTER_INTERVAL_MS = 300 +const TRAILING_NEWLINES_REGEX = /[\r\n]+$/ +const SEND_MODES = ['enter', 'ctrl-enter', 'double-enter'] as const +type SendMode = (typeof SEND_MODES)[number] +const sendMode = useLocalStorage('ui/chat/settings/send-mode', 'enter') +const lastEnterTime = ref(0) const providersStore = useProvidersStore() const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore()) @@ -37,6 +43,11 @@ const { ingest, onAfterMessageComposed } = chatOrchestrator const { messages } = storeToRefs(chatSession) const { audioContext } = useAudioContext() const { t } = useI18n() +const sendModeLabels = computed>(() => ({ + 'enter': t('stage.send-mode.enter'), + 'ctrl-enter': t('stage.send-mode.ctrl-enter'), + 'double-enter': t('stage.send-mode.double-enter'), +})) // Transcription pipeline const hearingStore = useHearingStore() @@ -131,6 +142,46 @@ async function handleSend() { } } +function sendFromKeyboard() { + messageInput.value = messageInput.value.replace(TRAILING_NEWLINES_REGEX, '') + void handleSend() +} + +function handleMessageInputKeydown(event: KeyboardEvent) { + if (isComposing.value || event.key !== 'Enter') + return + + const hasControl = event.ctrlKey || event.metaKey + const hasShift = event.shiftKey + + switch (sendMode.value) { + case 'enter': + if (!hasShift && !hasControl) { + event.preventDefault() + sendFromKeyboard() + } + return + case 'ctrl-enter': + if (hasControl) { + event.preventDefault() + sendFromKeyboard() + } + return + case 'double-enter': + if (!hasShift && !hasControl) { + const now = Date.now() + if (now - lastEnterTime.value < DOUBLE_ENTER_INTERVAL_MS) { + event.preventDefault() + sendFromKeyboard() + lastEnterTime.value = 0 + } + else { + lastEnterTime.value = now + } + } + } +} + watch(hearingPopoverOpen, async (value) => { if (value) { await askPermission() @@ -392,6 +443,10 @@ watch(autoSendEnabled, (enabled) => { console.info('[ChatArea] Auto-send disabled, cleared pending text') } }) + +watch(sendMode, () => { + lastEnterTime.value = 0 +})