feat(stage-tamagotchi,stage-web): add option for chat area send key (#851)

This commit is contained in:
cheesemori
2026-03-29 16:53:46 +08:00
committed by GitHub
parent 5285f269a8
commit edecae3625
12 changed files with 280 additions and 13 deletions
@@ -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<SendMode>('ui/chat/settings/send-mode', 'enter')
const lastEnterTime = ref(0)
const sendModeLabels = computed<Record<SendMode, string>>(() => ({
'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[])
</script>
@@ -124,17 +181,74 @@ const historyMessages = computed(() => messages.value as unknown as ChatHistoryI
:streaming-message="streamingMessage"
/>
</div>
<div v-if="attachments.length > 0" class="flex flex-wrap gap-2 border-t border-primary-100 p-2">
<div
v-if="attachments.length > 0"
:class="[
'flex flex-wrap gap-2 border-t border-primary-100 p-2',
]"
>
<div v-for="(attachment, index) in attachments" :key="index" class="relative">
<img :src="attachment.url" class="h-20 w-20 rounded-md object-cover">
<button class="absolute right-1 top-1 h-5 w-5 flex items-center justify-center rounded-full bg-red-500 text-xs text-white" @click="removeAttachment(index)">
<img :src="attachment.url" :class="['h-20 w-20 rounded-md object-cover']">
<button
:class="[
'absolute right-1 top-1 h-5 w-5 flex items-center justify-center rounded-full',
'bg-red-500 text-xs text-white',
]"
@click="removeAttachment(index)"
>
&times;
</button>
</div>
</div>
<div class="flex items-center justify-end gap-2 py-1">
<div :class="['flex items-center justify-end gap-2 py-1']">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
:class="[
'max-h-[10lh] min-h-[1lh] flex items-center justify-center rounded-md p-2 outline-none',
'transition-colors transition-transform active:scale-95',
]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
:title="t('stage.send-mode.title')"
>
<div class="i-solar:keyboard-bold-duotone" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
side="top"
:side-offset="8"
:class="[
'z-50 min-w-[180px] rounded-lg border border-primary-200 bg-white p-1 shadow-xl',
'dark:border-primary-700 dark:bg-neutral-800',
'flex flex-col gap-1',
]"
>
<DropdownMenuItem
v-for="mode in SEND_MODES"
:key="mode"
:class="[
'w-full flex cursor-pointer items-center rounded-md px-3 py-2 text-left text-xs outline-none transition-colors',
'hover:bg-primary-100 dark:hover:bg-primary-900/50',
sendMode === mode ? 'bg-primary-50 text-primary-600 font-semibold dark:bg-primary-900/20 dark:text-primary-300' : 'text-neutral-500',
]"
@select="sendMode = mode"
>
<div class="mr-2 h-4 w-4 flex shrink-0 items-center justify-center">
<div v-if="sendMode === mode" class="i-ph:check-bold text-base" />
</div>
<span>{{ sendModeLabels[mode] }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<button
class="max-h-[10lh] min-h-[1lh]"
:class="[
'max-h-[10lh] min-h-[1lh]',
]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
hover:text="red-500 dark:red-400"
@@ -147,6 +261,7 @@ const historyMessages = computed(() => messages.value as unknown as ChatHistoryI
</div>
<BasicTextarea
v-model="messageInput"
:submit-on-enter="false"
:placeholder="t('stage.message')"
class="ph-no-capture"
text="primary-600 dark:primary-100 placeholder:primary-500 dark:placeholder:primary-200"
@@ -157,7 +272,7 @@ const historyMessages = computed(() => 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"
/>
</div>
+5
View File
@@ -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:
+5
View File
@@ -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:
+5
View File
@@ -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:
+5
View File
@@ -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:
+5
View File
@@ -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:
+5
View File
@@ -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:
+5
View File
@@ -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:
@@ -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:
@@ -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:
@@ -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<SendMode>('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<Record<SendMode, string>>(() => ({
'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
})
</script>
<template>
@@ -405,6 +460,7 @@ watch(autoSendEnabled, (enabled) => {
>
<BasicTextarea
v-model="messageInput"
:submit-on-enter="false"
:placeholder="t('stage.message')"
text="primary-600 dark:primary-100 placeholder:primary-500 dark:placeholder:primary-200"
bg="transparent"
@@ -414,7 +470,7 @@ watch(autoSendEnabled, (enabled) => {
:class="{
'transition-colors-none placeholder:transition-colors-none': themeColorsHueDynamic,
}"
@submit="handleSend"
@keydown="handleMessageInputKeydown"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
@@ -423,11 +479,56 @@ watch(autoSendEnabled, (enabled) => {
<div
absolute bottom-2 left-2 z-10 flex items-center gap-2
>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
:class="[
'h-8 w-8 flex items-center justify-center rounded-md outline-none transition-all duration-200 active:scale-95',
'text-lg text-neutral-500 dark:text-neutral-400',
]"
:title="t('stage.send-mode.title')"
>
<div class="i-solar:keyboard-bold-duotone h-5 w-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="top"
align="start"
:side-offset="8"
:class="[
'z-50 min-w-[180px] rounded-xl border border-neutral-200/60 bg-neutral-50/90 p-1',
'shadow-lg backdrop-blur-md dark:border-neutral-800/30 dark:bg-neutral-900/80',
'flex flex-col gap-1',
]"
>
<DropdownMenuItem
v-for="mode in SEND_MODES"
:key="mode"
:class="[
'w-full flex cursor-pointer items-center rounded-lg px-3 py-2 text-xs outline-none transition-colors',
'hover:bg-primary-100/60 dark:hover:bg-primary-900/40',
sendMode === mode ? 'bg-primary-100/60 text-primary-600 font-medium dark:bg-primary-900/40 dark:text-primary-300' : 'text-neutral-600 dark:text-neutral-300',
]"
@select="sendMode = mode"
>
<div class="mr-2 h-4 w-4 flex items-center justify-center">
<div v-if="sendMode === mode" class="i-ph:check-bold h-4 w-4" />
</div>
<span>{{ sendModeLabels[mode] }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<!-- Microphone icon button -->
<PopoverRoot v-model:open="hearingPopoverOpen">
<PopoverTrigger as-child>
<button
class="h-8 w-8 flex items-center justify-center rounded-md outline-none transition-all duration-200 active:scale-95"
:class="[
'h-8 w-8 flex items-center justify-center rounded-md outline-none',
'transition-all duration-200 active:scale-95',
]"
text="lg neutral-500 dark:neutral-400"
:title="t('settings.hearing.title')"
>
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
const props = withDefaults(defineProps<{
defaultHeight?: string
}>()
submitOnEnter?: boolean
}>(), {
submitOnEnter: true,
})
const events = defineEmits<{
(event: 'submit', message: string): void
@@ -18,6 +21,9 @@ const textareaRef = ref<HTMLTextAreaElement>()
const textareaHeight = ref('auto')
function onKeyDown(e: KeyboardEvent) {
if (!props.submitOnEnter)
return
if (e.code === 'Enter' && !e.shiftKey) { // just block Enter is enough, Shift+Enter by default generates a newline
e.preventDefault()
events('submit', input.value)