424125843f
Allow sending multiple messages while a run is active. Messages are queued on the server and processed sequentially after each run completes. Each completed assistant message triggers speech playback independently, and the UI shows queue status with a badge indicator. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
644 lines
16 KiB
Vue
644 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import type { Attachment } from '@/stores/hermes/chat'
|
|
import { useChatStore } from '@/stores/hermes/chat'
|
|
import { useAppStore } from '@/stores/hermes/app'
|
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
|
import { fetchContextLength } from '@/api/hermes/sessions'
|
|
import { setModelContext } from '@/api/hermes/model-context'
|
|
import { NButton, NTooltip, NSwitch, NModal, NInputNumber, useMessage } from 'naive-ui'
|
|
import { computed, ref, onMounted, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
const chatStore = useChatStore()
|
|
const { t } = useI18n()
|
|
const message = useMessage()
|
|
const inputText = ref('')
|
|
const textareaRef = ref<HTMLTextAreaElement>()
|
|
const fileInputRef = ref<HTMLInputElement>()
|
|
const attachments = ref<Attachment[]>([])
|
|
const isDragging = ref(false)
|
|
const dragCounter = ref(0)
|
|
const isComposing = ref(false)
|
|
|
|
// 自动播放语音开关
|
|
const autoPlaySpeech = ref(false)
|
|
|
|
// 从 localStorage 读取设置
|
|
onMounted(() => {
|
|
const saved = localStorage.getItem('autoPlaySpeech')
|
|
if (saved !== null) {
|
|
autoPlaySpeech.value = saved === 'true'
|
|
// 同步到 chat store
|
|
chatStore.setAutoPlaySpeech(autoPlaySpeech.value)
|
|
}
|
|
})
|
|
|
|
// 监听变化并保存
|
|
watch(autoPlaySpeech, (value) => {
|
|
localStorage.setItem('autoPlaySpeech', String(value))
|
|
// 通知 chat store
|
|
chatStore.setAutoPlaySpeech(value)
|
|
})
|
|
|
|
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
|
|
|
|
// --- Context info ---
|
|
|
|
const contextLength = ref(200000)
|
|
const FALLBACK_CONTEXT = 200000
|
|
|
|
// Context length editing
|
|
const showContextEditModal = ref(false)
|
|
const editingContextLimit = ref(200000)
|
|
const isSavingContextLimit = ref(false)
|
|
|
|
async function handleEditContextLimit() {
|
|
editingContextLimit.value = contextLength.value
|
|
showContextEditModal.value = true
|
|
}
|
|
|
|
async function saveContextLimit() {
|
|
if (!editingContextLimit.value || editingContextLimit.value <= 0) {
|
|
message.error(t('chat.contextEditInvalid'))
|
|
return
|
|
}
|
|
|
|
isSavingContextLimit.value = true
|
|
try {
|
|
const appStore = useAppStore()
|
|
const provider = appStore.selectedProvider || ''
|
|
const model = appStore.selectedModel || ''
|
|
|
|
if (!provider || !model) {
|
|
message.error(t('chat.contextEditFailed'))
|
|
return
|
|
}
|
|
|
|
await setModelContext(provider, model, editingContextLimit.value)
|
|
contextLength.value = editingContextLimit.value
|
|
showContextEditModal.value = false
|
|
message.success(t('chat.contextEditSuccess'))
|
|
} catch (err: any) {
|
|
message.error(`${t('chat.contextEditFailed')}: ${err.message || ''}`)
|
|
} finally {
|
|
isSavingContextLimit.value = false
|
|
}
|
|
}
|
|
|
|
async function loadContextLength() {
|
|
try {
|
|
const profile = useProfilesStore().activeProfileName || undefined
|
|
contextLength.value = await fetchContextLength(profile)
|
|
} catch {
|
|
contextLength.value = FALLBACK_CONTEXT
|
|
}
|
|
}
|
|
|
|
onMounted(loadContextLength)
|
|
watch(() => useProfilesStore().activeProfileName, loadContextLength)
|
|
watch(() => useAppStore().selectedModel, loadContextLength)
|
|
|
|
const totalTokens = computed(() => {
|
|
const input = chatStore.activeSession?.inputTokens ?? 0
|
|
const output = chatStore.activeSession?.outputTokens ?? 0
|
|
return input + output
|
|
})
|
|
|
|
const remainingTokens = computed(() => Math.max(0, contextLength.value - totalTokens.value))
|
|
|
|
const usagePercent = computed(() =>
|
|
Math.min((totalTokens.value / contextLength.value) * 100, 100),
|
|
)
|
|
|
|
function formatTokens(n: number): string {
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
|
|
return String(n)
|
|
}
|
|
|
|
// --- File attachment helpers ---
|
|
|
|
function addFile(file: File) {
|
|
if (attachments.value.find(a => a.name === file.name)) return
|
|
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
|
const url = URL.createObjectURL(file)
|
|
attachments.value.push({
|
|
id,
|
|
name: file.name,
|
|
type: file.type,
|
|
size: file.size,
|
|
url,
|
|
file,
|
|
})
|
|
}
|
|
|
|
function handleAttachClick() {
|
|
fileInputRef.value?.click()
|
|
}
|
|
|
|
function handleFileChange(e: Event) {
|
|
const input = e.target as HTMLInputElement
|
|
if (!input.files) return
|
|
for (const file of input.files) addFile(file)
|
|
input.value = ''
|
|
}
|
|
|
|
// --- Paste image ---
|
|
|
|
function handlePaste(e: ClipboardEvent) {
|
|
const items = Array.from(e.clipboardData?.items || [])
|
|
const imageItems = items.filter(i => i.type.startsWith('image/'))
|
|
if (!imageItems.length) return
|
|
e.preventDefault()
|
|
for (const item of imageItems) {
|
|
const blob = item.getAsFile()
|
|
if (!blob) continue
|
|
const ext = item.type.split('/')[1] || 'png'
|
|
const file = new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type })
|
|
addFile(file)
|
|
}
|
|
}
|
|
|
|
// --- Drag and drop ---
|
|
|
|
function handleDragOver(e: DragEvent) {
|
|
e.preventDefault()
|
|
}
|
|
|
|
function handleDragEnter(e: DragEvent) {
|
|
e.preventDefault()
|
|
if (e.dataTransfer?.types.includes('Files')) {
|
|
dragCounter.value++
|
|
isDragging.value = true
|
|
}
|
|
}
|
|
|
|
function handleDragLeave() {
|
|
dragCounter.value--
|
|
if (dragCounter.value <= 0) {
|
|
dragCounter.value = 0
|
|
isDragging.value = false
|
|
}
|
|
}
|
|
|
|
function handleDrop(e: DragEvent) {
|
|
e.preventDefault()
|
|
dragCounter.value = 0
|
|
isDragging.value = false
|
|
const files = Array.from(e.dataTransfer?.files || [])
|
|
if (!files.length) return
|
|
for (const file of files) addFile(file)
|
|
textareaRef.value?.focus()
|
|
}
|
|
|
|
// --- Send ---
|
|
|
|
function handleSend() {
|
|
const text = inputText.value.trim()
|
|
if (!text && attachments.value.length === 0) return
|
|
|
|
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
|
|
inputText.value = ''
|
|
attachments.value = []
|
|
|
|
if (textareaRef.value) {
|
|
textareaRef.value.style.height = 'auto'
|
|
}
|
|
}
|
|
|
|
function handleCompositionStart() {
|
|
isComposing.value = true
|
|
}
|
|
|
|
function handleCompositionEnd() {
|
|
requestAnimationFrame(() => {
|
|
isComposing.value = false
|
|
})
|
|
}
|
|
|
|
function isImeEnter(e: KeyboardEvent): boolean {
|
|
return isComposing.value || e.isComposing || e.keyCode === 229
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key !== 'Enter' || e.shiftKey) return
|
|
if (isImeEnter(e)) return
|
|
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
|
|
function handleInput(e: Event) {
|
|
const el = e.target as HTMLTextAreaElement
|
|
el.style.height = 'auto'
|
|
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
|
}
|
|
|
|
function removeAttachment(id: string) {
|
|
const idx = attachments.value.findIndex(a => a.id === id)
|
|
if (idx !== -1) {
|
|
URL.revokeObjectURL(attachments.value[idx].url)
|
|
attachments.value.splice(idx, 1)
|
|
}
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes < 1024) return bytes + ' B'
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
|
}
|
|
|
|
function isImage(type: string): boolean {
|
|
return type.startsWith('image/')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="chat-input-area">
|
|
<!-- Top bar: attach + auto play speech + context info -->
|
|
<div class="input-top-bar">
|
|
<NTooltip trigger="hover">
|
|
<template #trigger>
|
|
<NButton quaternary size="tiny" @click="handleAttachClick" circle>
|
|
<template #icon>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
</template>
|
|
</NButton>
|
|
</template>
|
|
{{ t('chat.attachFiles') }}
|
|
</NTooltip>
|
|
|
|
<div class="auto-play-speech-switch">
|
|
<NTooltip trigger="hover">
|
|
<template #trigger>
|
|
<div class="switch-label">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
|
</svg>
|
|
</div>
|
|
</template>
|
|
{{ t('chat.autoPlaySpeech') }}
|
|
</NTooltip>
|
|
<NSwitch
|
|
size="small"
|
|
v-model:value="autoPlaySpeech"
|
|
:round="false"
|
|
/>
|
|
</div>
|
|
|
|
<span v-if="totalTokens > 0" class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
|
|
{{ formatTokens(totalTokens) }} /
|
|
<NTooltip trigger="hover">
|
|
<template #trigger>
|
|
<span class="context-limit-editable" @click="handleEditContextLimit">
|
|
{{ formatTokens(contextLength) }}
|
|
</span>
|
|
</template>
|
|
<span>{{ t('chat.contextClickToEdit') }}</span>
|
|
</NTooltip>
|
|
· {{ t('chat.contextRemaining') }} {{ formatTokens(remainingTokens) }}
|
|
</span>
|
|
<div v-if="totalTokens > 0" class="context-bar">
|
|
<div
|
|
class="context-bar-fill"
|
|
:class="{
|
|
'context-bar-warn': usagePercent > 60 && usagePercent <= 80,
|
|
'context-bar-danger': usagePercent > 80,
|
|
}"
|
|
:style="{ width: `${usagePercent}%` }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Attachment previews -->
|
|
<div v-if="attachments.length > 0" class="attachment-previews">
|
|
<div
|
|
v-for="att in attachments"
|
|
:key="att.id"
|
|
class="attachment-preview"
|
|
:class="{ image: isImage(att.type) }"
|
|
>
|
|
<template v-if="isImage(att.type)">
|
|
<img :src="att.url" :alt="att.name" class="attachment-thumb" />
|
|
</template>
|
|
<template v-else>
|
|
<div class="attachment-file">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
|
<span class="file-name">{{ att.name }}</span>
|
|
<span class="file-size">{{ formatSize(att.size) }}</span>
|
|
</div>
|
|
</template>
|
|
<button class="attachment-remove" @click="removeAttachment(att.id)">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="input-wrapper"
|
|
:class="{ 'drag-over': isDragging }"
|
|
@dragover="handleDragOver"
|
|
@dragenter="handleDragEnter"
|
|
@dragleave="handleDragLeave"
|
|
@drop="handleDrop"
|
|
>
|
|
<input
|
|
ref="fileInputRef"
|
|
type="file"
|
|
multiple
|
|
class="file-input-hidden"
|
|
@change="handleFileChange"
|
|
/>
|
|
<textarea
|
|
ref="textareaRef"
|
|
v-model="inputText"
|
|
class="input-textarea"
|
|
:placeholder="t('chat.inputPlaceholder')"
|
|
rows="1"
|
|
@keydown="handleKeydown"
|
|
@compositionstart="handleCompositionStart"
|
|
@compositionend="handleCompositionEnd"
|
|
@input="handleInput"
|
|
@paste="handlePaste"
|
|
></textarea>
|
|
<div class="input-actions">
|
|
<NButton
|
|
v-if="chatStore.isStreaming"
|
|
size="small"
|
|
type="error"
|
|
:disabled="chatStore.isAborting"
|
|
@click="chatStore.stopStreaming()"
|
|
>
|
|
{{ t('chat.stop') }}
|
|
</NButton>
|
|
<NButton
|
|
size="small"
|
|
type="primary"
|
|
:disabled="!canSend"
|
|
@click="handleSend"
|
|
>
|
|
<template #icon>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
</template>
|
|
{{ t('chat.send') }}
|
|
</NButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Context Length Edit Modal -->
|
|
<NModal
|
|
v-model:show="showContextEditModal"
|
|
:title="t('chat.contextEditTitle')"
|
|
:mask-closable="true"
|
|
preset="card"
|
|
style="width: 400px"
|
|
>
|
|
<div class="context-edit-content">
|
|
<p style="margin-bottom: 16px; color: #666;">
|
|
{{ t('chat.contextEditDesc') }}
|
|
</p>
|
|
<NInputNumber
|
|
v-model:value="editingContextLimit"
|
|
:min="1000"
|
|
:max="10000000"
|
|
:step="1000"
|
|
:show-button="false"
|
|
:placeholder="t('chat.contextEditPlaceholder')"
|
|
style="width: 100%"
|
|
>
|
|
<template #suffix>
|
|
<span style="color: #999;">tokens</span>
|
|
</template>
|
|
</NInputNumber>
|
|
<div style="margin-top: 12px; font-size: 12px; color: #999;">
|
|
{{ t('chat.contextEditHint') }}
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<div style="display: flex; justify-content: flex-end; gap: 8px;">
|
|
<NButton @click="showContextEditModal = false" :disabled="isSavingContextLimit">
|
|
{{ t('chat.contextEditCancel') }}
|
|
</NButton>
|
|
<NButton type="primary" @click="saveContextLimit" :loading="isSavingContextLimit">
|
|
{{ t('chat.contextEditSave') }}
|
|
</NButton>
|
|
</div>
|
|
</template>
|
|
</NModal>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
@use '@/styles/variables' as *;
|
|
|
|
.chat-input-area {
|
|
padding: 12px 20px 16px;
|
|
border-top: 1px solid $border-color;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.input-top-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 0 0 6px;
|
|
}
|
|
|
|
.auto-play-speech-switch {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 0 8px;
|
|
border-left: 1px solid $border-light;
|
|
margin-left: 4px;
|
|
|
|
.switch-label {
|
|
display: flex;
|
|
align-items: center;
|
|
color: $text-muted;
|
|
font-size: 12px;
|
|
|
|
svg {
|
|
opacity: 0.7;
|
|
}
|
|
}
|
|
}
|
|
|
|
.context-info {
|
|
font-size: 11px;
|
|
color: $text-muted;
|
|
|
|
&.context-warning {
|
|
color: #e8a735;
|
|
}
|
|
}
|
|
|
|
.context-limit-editable {
|
|
cursor: pointer;
|
|
border-bottom: 1px dashed transparent;
|
|
transition: all 0.2s ease;
|
|
padding: 0 2px;
|
|
|
|
&:hover {
|
|
border-bottom-color: $text-muted;
|
|
background: rgba(128, 128, 128, 0.1);
|
|
border-radius: 2px;
|
|
}
|
|
}
|
|
|
|
.context-bar {
|
|
width: 60px;
|
|
height: 4px;
|
|
background: rgba(128, 128, 128, 0.2);
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.context-bar-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, rgba(128, 128, 128, 0.3), rgba(128, 128, 128, 0.6));
|
|
border-radius: 2px;
|
|
transition: width 0.3s ease;
|
|
|
|
&.context-bar-warn {
|
|
background: linear-gradient(90deg, #c98a1a, #e8a735);
|
|
}
|
|
|
|
&.context-bar-danger {
|
|
background: linear-gradient(90deg, #c43a2a, #e85d4a);
|
|
}
|
|
}
|
|
|
|
.attachment-previews {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
padding: 0 0 10px;
|
|
}
|
|
|
|
.attachment-preview {
|
|
position: relative;
|
|
border-radius: $radius-sm;
|
|
overflow: hidden;
|
|
background-color: $bg-secondary;
|
|
border: 1px solid $border-color;
|
|
|
|
&.image {
|
|
width: 64px;
|
|
height: 64px;
|
|
}
|
|
}
|
|
|
|
.attachment-thumb {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.attachment-file {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 2px;
|
|
padding: 8px 12px;
|
|
min-width: 80px;
|
|
max-width: 140px;
|
|
color: $text-secondary;
|
|
|
|
.file-name {
|
|
font-size: 11px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.file-size {
|
|
font-size: 10px;
|
|
color: $text-muted;
|
|
}
|
|
}
|
|
|
|
.attachment-remove {
|
|
position: absolute;
|
|
top: 2px;
|
|
right: 2px;
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
border: none;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
color: var(--text-on-overlay);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: opacity $transition-fast;
|
|
|
|
.attachment-preview:hover & {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.file-input-hidden {
|
|
display: none;
|
|
}
|
|
|
|
.input-wrapper {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
background-color: $bg-input;
|
|
border: 1px solid $border-color;
|
|
border-radius: $radius-md;
|
|
padding: 10px 12px;
|
|
transition: border-color $transition-fast, background-color $transition-fast;
|
|
|
|
&:focus-within {
|
|
border-color: $accent-primary;
|
|
}
|
|
|
|
.dark & {
|
|
background-color: #333333;
|
|
}
|
|
}
|
|
|
|
.input-textarea {
|
|
flex: 1;
|
|
background: none;
|
|
border: none;
|
|
outline: none;
|
|
color: $text-primary;
|
|
font-family: $font-ui;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
resize: none;
|
|
max-height: 100px;
|
|
min-height: 20px;
|
|
overflow-y: auto;
|
|
|
|
&::placeholder {
|
|
color: $text-muted;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
}
|
|
|
|
.input-actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-shrink: 0;
|
|
align-items: center;
|
|
}
|
|
|
|
// Drag-over state
|
|
.input-wrapper.drag-over {
|
|
border-color: var(--accent-info);
|
|
border-style: dashed;
|
|
background-color: rgba(var(--accent-info-rgb), 0.04);
|
|
}
|
|
</style>
|