feat(stage-tamagotchi,stage-web): add option for chat area send key (#851)
This commit is contained in:
@@ -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)"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: 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)
|
||||
|
||||
Reference in New Issue
Block a user