Files
hermes-web/packages/client/src/components/hermes/chat/ChatInput.vue
T
toor c3eeea4187 fix(chat): repair Arco migration fallout in the chat page
The Phase 006 walkthrough surfaced four issues the automated checks could
not see. Dead naive-ui class names left behind by the migration stopped
matching, tooltips that carried their label as trailing default-slot text
leaked that text into the flex row, the workspace panel squeezed the chat
column until the input toolbar wrapped, and the Escape fallback still
looked for naive-ui's modal mask.

Tooltips now pass their label through the content prop, the tool panel
reserves a minimum width for the chat column, and Escape finds the visible
Arco modal and clicks its close button.
2026-10-18 03:11:35 +08:00

2016 lines
54 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 { useSettingsStore } from '@/stores/hermes/settings'
import { fetchContextLength } from '@/api/hermes/sessions'
import { setModelContext } from '@/api/hermes/model-context'
import { fetchSkills, type SkillCategory, type SkillInfo } from '@/api/hermes/skills'
import { useMessage } from '@/composables/useMessage'
import { computed, ref, nextTick, onMounted, onUnmounted, watch, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import VoiceDialogueControls from './VoiceDialogueControls.vue'
import { useMicRecorder } from '@/composables/useMicRecorder'
import { useGlobalSpeech } from '@/composables/useSpeech'
import { useVoiceDialogue } from '@/composables/useVoiceDialogue'
import { transcribeSpeech } from '@/api/hermes/stt'
import type { StoredSttProvider } from '@/api/hermes/stt-settings'
import { useSttSettings } from '@/composables/useSttSettings'
import { useBrowserSpeechRecognition } from '@/composables/useBrowserSpeechRecognition'
import { BRIDGE_SESSION_COMMAND_DEFINITIONS } from '@/utils/hermes/bridge-session-commands'
import { clampChatInputHeight, isMobileChatInputViewport } from '@/utils/chat-input-height'
const chatStore = useChatStore()
const appStore = useAppStore()
const profilesStore = useProfilesStore()
const settingsStore = useSettingsStore()
const { t } = useI18n()
const message = useMessage()
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
const props = withDefaults(defineProps<{
modelLabel?: string
}>(), {
modelLabel: '',
})
const emit = defineEmits<{
modelClick: []
}>()
const reasoningEffortOptions = computed(() => [
{ label: t('chat.reasoningEffort.options.default'), value: '' },
{ label: t('chat.reasoningEffort.options.none'), value: 'none' },
{ label: t('chat.reasoningEffort.options.minimal'), value: 'minimal' },
{ label: t('chat.reasoningEffort.options.low'), value: 'low' },
{ label: t('chat.reasoningEffort.options.medium'), value: 'medium' },
{ label: t('chat.reasoningEffort.options.high'), value: 'high' },
{ label: t('chat.reasoningEffort.options.xhigh'), value: 'xhigh' },
])
const currentReasoningEffort = computed<string>(() =>
chatStore.activeSession?.reasoningEffort || ''
)
const reasoningEffortLabel = computed<string>(() => {
const v = currentReasoningEffort.value
if (!v) return t('chat.reasoningEffort.defaultLabel')
const opt = reasoningEffortOptions.value.find(o => o.value === v)
return opt?.label || v
})
function onReasoningEffortChange(value: unknown) {
const sid = chatStore.activeSessionId
if (!sid) return
chatStore.setSessionReasoningEffort(sid, value ? String(value) : '')
}
function handleModelButtonClick() {
emit('modelClick')
}
const compactModelLabel = computed(() => {
const label = props.modelLabel || t('models.selectModel')
const parts = label.split('/').filter(Boolean)
return parts[parts.length - 1] || label
})
const DRAFT_STORAGE_KEY = 'hermes_chat_input_drafts_v1'
type DraftMap = Record<string, string>
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const commandDropdownRef = ref<HTMLDivElement>()
const fileInputRef = ref<HTMLInputElement>()
const attachments = ref<Attachment[]>([])
const isDragging = ref(false)
const dragCounter = ref(0)
const isComposing = ref(false)
const isMobileViewport = ref(typeof window !== 'undefined' ? isMobileChatInputViewport(window.innerWidth) : false)
const manualTextareaResize = ref(false)
const speech = useGlobalSpeech()
const micRecorder = useMicRecorder({
messages: {
unsupported: t('chat.voiceInput.microphoneUnsupported'),
recordingFailed: t('chat.voiceInput.microphoneRecordingFailed'),
},
})
const sttSettings = useSttSettings()
const browserRecognition = useBrowserSpeechRecognition({
messages: {
unsupported: t('chat.voiceInput.browserSpeechUnsupported'),
failed: t('chat.voiceInput.browserSpeechFailed'),
failedWithReason: (reason) => t('chat.voiceInput.browserSpeechFailedWithReason', { error: reason }),
},
})
const activeVoiceCaptureMode = ref<'browser' | 'backend' | null>(null)
const configuredTextareaHeight = computed(() =>
isMobileViewport.value ? null : clampChatInputHeight(settingsStore.display.chat_input_height),
)
type SlashCommandOption = {
name: string
args: string
description: string
insertText?: string
key: string
opensSkillPicker?: boolean
}
function normalizeVoiceTranscript(text: string) {
return text.replace(/\s+/g, ' ').trim()
}
function backendTranscribeOptions(): {
provider: StoredSttProvider
language?: string
prompt?: string
} {
if (sttSettings.provider.value === 'custom') {
return {
provider: 'custom',
language: sttSettings.customLanguage.value.trim() || undefined,
prompt: sttSettings.customPrompt.value.trim() || undefined,
}
}
if (sttSettings.provider.value === 'doubao') {
return {
provider: 'doubao',
}
}
return {
provider: 'openai',
language: sttSettings.openaiLanguage.value.trim() || undefined,
prompt: sttSettings.openaiPrompt.value.trim() || undefined,
}
}
function browserCaptureLanguage() {
return sttSettings.openaiLanguage.value.trim() || sttSettings.customLanguage.value.trim() || ''
}
function insertVoiceTranscriptIntoInput(text: string) {
const normalizedTranscript = normalizeVoiceTranscript(text)
if (!normalizedTranscript) return
const el = textareaRef.value
const currentValue = inputText.value
const selectionStart = el?.selectionStart ?? currentValue.length
const selectionEnd = el?.selectionEnd ?? selectionStart
const before = currentValue.slice(0, selectionStart)
const after = currentValue.slice(selectionEnd)
const prefix = before && !/\s$/.test(before) ? ' ' : ''
const suffix = after && !/^\s/.test(after) ? ' ' : ''
const nextValue = `${before}${prefix}${normalizedTranscript}${suffix}${after}`
const nextCursorPosition = before.length + prefix.length + normalizedTranscript.length
inputText.value = nextValue
slashActive.value = false
nextTick(() => {
const textarea = textareaRef.value
if (!textarea) return
textarea.focus()
textarea.setSelectionRange(nextCursorPosition, nextCursorPosition)
autoSizeTextarea(textarea)
})
}
const voiceDialogue = useVoiceDialogue({
transcribe: async (audio) => {
const { provider, language, prompt } = backendTranscribeOptions()
return transcribeSpeech({ audio, provider, language, prompt })
},
sendMessage: async (text) => {
insertVoiceTranscriptIntoInput(text)
},
stopOutputAudio: () => speech.stop(true),
})
const voiceDialogueTranscript = computed(() => {
if (activeVoiceCaptureMode.value !== 'browser' || voiceDialogue.status.value !== 'capturing') {
return voiceDialogue.transcript.value
}
return normalizeVoiceTranscript([
browserRecognition.transcript.value,
browserRecognition.partialTranscript.value,
].filter(Boolean).join(' '))
})
const shouldShowBrowserRecognitionError = computed(() =>
sttSettings.provider.value === 'browser' || activeVoiceCaptureMode.value === 'browser',
)
const voiceDialogueError = computed(() =>
voiceDialogue.error.value?.message
?? (shouldShowBrowserRecognitionError.value ? browserRecognition.error.value?.message : null)
?? micRecorder.state.value.error?.message
?? null,
)
const bridgeCommands = computed<SlashCommandOption[]>(() =>
BRIDGE_SESSION_COMMAND_DEFINITIONS.map(command => ({
key: command.key,
name: command.name,
args: command.argsKey ? t(command.argsKey) : command.args || '',
description: t(command.descriptionKey),
insertText: command.insertText,
opensSkillPicker: command.opensSkillPicker,
}))
)
const slashActive = ref(false)
const slashQuery = ref('')
const slashActiveIndex = ref(0)
const skillCategories = ref<SkillCategory[]>([])
const showSkillPicker = ref(false)
const skillSearch = ref('')
const skillPickerLoading = ref(false)
let skillsLoadedKey = ''
let skillsLoadRequest: Promise<void> | null = null
const isBridgeSession = computed(() => chatStore.activeSession?.source === 'cli')
const isForkCommandSession = computed(() => !!chatStore.activeSession)
const skillPickerItems = computed(() => {
const byName = new Map<string, SkillInfo>()
for (const category of skillCategories.value) {
for (const skill of category.skills || []) {
if (skill.enabled === false) continue
if (!byName.has(skill.name)) byName.set(skill.name, skill)
}
}
return [...byName.values()].map(skill => {
const commandName = skillCommandName(skill.name)
return {
key: `skill:${commandName}`,
name: skill.name,
commandName,
description: skill.description || skill.name,
}
})
})
const filteredBridgeCommands = computed(() => {
const query = slashQuery.value.trim().toLowerCase()
const commands = isBridgeSession.value
? bridgeCommands.value
: isForkCommandSession.value
? bridgeCommands.value.filter(command => command.name === 'fork')
: []
if (!query) return commands
return commands.filter((command) => {
const name = command.name.toLowerCase()
const insertText = command.insertText?.toLowerCase()
const description = command.description.toLowerCase()
return name.startsWith(query) || insertText?.startsWith(query) || description.includes(query)
})
})
const filteredSkillPickerItems = computed(() => {
const query = skillSearch.value.trim().toLowerCase()
if (!query) return skillPickerItems.value
return skillPickerItems.value.filter(skill =>
skill.name.toLowerCase().includes(query)
|| skill.commandName.includes(query)
|| skill.description.toLowerCase().includes(query),
)
})
function skillCommandName(name: string) {
return name
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/_/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
function currentSkillsKey() {
return chatStore.activeSession?.profile || profilesStore.activeProfileName || 'default'
}
async function loadSkills() {
if (!isBridgeSession.value) return
const key = currentSkillsKey()
if (skillsLoadedKey === key || skillsLoadRequest) return skillsLoadRequest
skillsLoadRequest = (async () => {
try {
const data = await fetchSkills(key)
if (currentSkillsKey() !== key) return
skillCategories.value = data.categories || []
skillsLoadedKey = key
} catch {
if (currentSkillsKey() !== key) return
skillCategories.value = []
skillsLoadedKey = key
} finally {
skillsLoadRequest = null
}
})()
return skillsLoadRequest
}
// 自定义高度拖拽
const textareaHeight = ref<number | null>(null) // null = auto
const inputWrapperStyle = computed(() => {
const height = textareaHeight.value ?? configuredTextareaHeight.value
if (height === null) return {}
return { minHeight: `${height + 71}px` }
})
function syncViewport() {
if (typeof window === 'undefined') return
isMobileViewport.value = isMobileChatInputViewport(window.innerWidth)
}
function resetTextareaHeight() {
manualTextareaResize.value = false
applyConfiguredTextareaHeight()
}
function autoSizeTextarea(el: HTMLTextAreaElement | undefined = textareaRef.value) {
if (!el || textareaHeight.value !== null) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 100)}px`
}
function focusTextareaFromInputWrapper(event: MouseEvent) {
if (event.defaultPrevented) return
const target = event.target instanceof Element ? event.target : null
if (!target) return
const interactiveTarget = target.closest([
'button',
'input',
'textarea',
'[role="button"]',
'.context-limit-editable',
'.input-toolbar',
'.resize-handle',
].join(','))
if (interactiveTarget) return
textareaRef.value?.focus()
}
function applyConfiguredTextareaHeight() {
if (manualTextareaResize.value) return
textareaHeight.value = configuredTextareaHeight.value
const textarea = textareaRef.value
if (!textarea) return
if (textareaHeight.value === null) {
autoSizeTextarea(textarea)
return
}
textarea.style.height = `${textareaHeight.value}px`
}
function startResize(e: MouseEvent) {
e.preventDefault()
const el = textareaRef.value
if (!el) return
manualTextareaResize.value = true
// 如果当前是 auto,用实际 clientHeight 作为起始值
const startHeight = el.clientHeight
const startY = e.clientY
function onMouseMove(e: MouseEvent) {
const deltaY = e.clientY - startY
// 往上拖 (deltaY < 0) → 高度增加
const newHeight = startHeight - deltaY
textareaHeight.value = clampChatInputHeight(newHeight) ?? textareaHeight.value
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// 自动播放语音开关
const autoPlaySpeech = ref(false)
const inputSettingsOptions = computed(() => [
{
key: 'autoPlaySpeech',
label: t('chat.autoPlaySpeech'),
icon: () => h('span', {
class: ['settings-check', { active: autoPlaySpeech.value }],
'aria-hidden': 'true',
}, autoPlaySpeech.value ? '✓' : ''),
},
{
key: 'toolTrace',
label: t('chat.showToolCalls'),
icon: () => h('span', {
class: ['settings-check', { active: toolTraceVisible.value }],
'aria-hidden': 'true',
}, toolTraceVisible.value ? '✓' : ''),
},
])
function readDraftMap(): DraftMap {
try {
const parsed = JSON.parse(localStorage.getItem(DRAFT_STORAGE_KEY) || '{}')
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
function getActiveDraftSessionId() {
return chatStore.activeSessionId || chatStore.activeSession?.id || ''
}
function loadDraftForActiveSession() {
const sessionId = getActiveDraftSessionId()
inputText.value = sessionId ? readDraftMap()[sessionId] || '' : ''
}
function saveDraftForActiveSession(value: string) {
const sessionId = getActiveDraftSessionId()
if (!sessionId) return
const drafts = readDraftMap()
if (value) {
drafts[sessionId] = value
} else {
delete drafts[sessionId]
}
if (Object.keys(drafts).length > 0) {
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(drafts))
} else {
localStorage.removeItem(DRAFT_STORAGE_KEY)
}
}
// 从 localStorage 读取设置
onMounted(() => {
loadDraftForActiveSession()
const saved = localStorage.getItem('autoPlaySpeech')
if (saved !== null) {
autoPlaySpeech.value = saved === 'true'
// 同步到 chat store
chatStore.setAutoPlaySpeech(autoPlaySpeech.value)
}
syncViewport()
window.addEventListener('resize', syncViewport)
nextTick(() => {
applyConfiguredTextareaHeight()
})
})
// 监听变化并保存
watch(autoPlaySpeech, (value) => {
localStorage.setItem('autoPlaySpeech', String(value))
// 通知 chat store
chatStore.setAutoPlaySpeech(value)
})
function handleInputSettingsSelect(selected: string | number | Record<string, any> | undefined) {
const key = typeof selected === 'object' ? selected?.key : selected
if (key === 'autoPlaySpeech') {
autoPlaySpeech.value = !autoPlaySpeech.value
return
}
if (key === 'toolTrace') {
toggleToolTraceVisible()
}
}
watch(inputText, (value) => {
saveDraftForActiveSession(value)
})
watch(() => chatStore.activeSession?.id, () => {
loadDraftForActiveSession()
nextTick(() => {
applyConfiguredTextareaHeight()
})
})
watch(configuredTextareaHeight, () => {
applyConfiguredTextareaHeight()
})
watch(() => settingsStore.display.chat_input_height, () => {
manualTextareaResize.value = false
applyConfiguredTextareaHeight()
})
watch(
() => [chatStore.activeSession?.profile, profilesStore.activeProfileName],
() => {
skillsLoadedKey = ''
skillCategories.value = []
},
)
const canSend = computed(() => inputText.value.trim().length > 0 || attachments.value.length > 0)
const sendButtonIsStop = computed(() => chatStore.isStreaming && !canSend.value)
function scrollCommandIntoView() {
nextTick(() => {
if (!commandDropdownRef.value) return
const active = commandDropdownRef.value.querySelector('.active') as HTMLElement | null
active?.scrollIntoView({ block: 'nearest', behavior: 'instant' })
})
}
function updateSlashState() {
if (!isBridgeSession.value && !isForkCommandSession.value) {
slashActive.value = false
return
}
const el = textareaRef.value
if (!el) return
const cursorPos = el.selectionStart
const beforeCursor = inputText.value.slice(0, cursorPos)
if (!beforeCursor.startsWith('/') || beforeCursor.includes(' ') || beforeCursor.includes('\n')) {
slashActive.value = false
return
}
slashQuery.value = beforeCursor.slice(1)
slashActiveIndex.value = 0
slashActive.value = filteredBridgeCommands.value.length > 0
}
function selectBridgeCommand(command: SlashCommandOption) {
if (command.opensSkillPicker) {
slashActive.value = false
void openSkillPicker()
return
}
inputText.value = `/${command.insertText || command.name} `
slashActive.value = false
nextTick(() => {
const el = textareaRef.value
if (!el) return
const pos = inputText.value.length
el.setSelectionRange(pos, pos)
el.focus()
})
}
async function openSkillPicker() {
if (!isBridgeSession.value) return
slashActive.value = false
skillSearch.value = ''
showSkillPicker.value = true
skillPickerLoading.value = true
try {
await loadSkills()
} finally {
skillPickerLoading.value = false
}
}
function selectSkill(skill: { commandName: string }) {
inputText.value = `/skill ${skill.commandName} `
showSkillPicker.value = false
nextTick(() => {
const el = textareaRef.value
if (!el) return
const pos = inputText.value.length
el.setSelectionRange(pos, pos)
el.focus()
})
}
// --- Context info ---
const contextLength = ref(256000)
const FALLBACK_CONTEXT = 256000
let contextLengthLoadedKey = ''
let contextLengthRequestKey = ''
let contextLengthRequest: Promise<void> | null = null
// Context length editing
const showContextEditModal = ref(false)
const editingContextLimit = ref(256000)
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 provider = chatStore.activeSession?.provider || appStore.selectedProvider || ''
const model = chatStore.activeSession?.model || appStore.selectedModel || ''
if (!provider || !model) {
message.error(t('chat.contextEditFailed'))
return
}
await setModelContext(provider, model, editingContextLimit.value)
contextLength.value = editingContextLimit.value
contextLengthLoadedKey = currentContextLengthKey()
showContextEditModal.value = false
message.success(t('chat.contextEditSuccess'))
} catch (err: any) {
message.error(`${t('chat.contextEditFailed')}: ${err.message || ''}`)
} finally {
isSavingContextLimit.value = false
}
}
function currentContextLengthParams() {
const activeSession = chatStore.activeSession
return {
profile: activeSession?.profile || profilesStore.activeProfileName || undefined,
provider: activeSession?.provider || undefined,
model: activeSession?.model || undefined,
}
}
function currentContextLengthKey() {
const params = currentContextLengthParams()
return `${params.profile || ''}|${params.provider || ''}|${params.model || ''}`
}
async function loadContextLength() {
const key = currentContextLengthKey()
if (key === contextLengthLoadedKey) return
if (key === contextLengthRequestKey && contextLengthRequest) return contextLengthRequest
contextLengthRequestKey = key
contextLengthRequest = (async () => {
const params = currentContextLengthParams()
try {
const value = await fetchContextLength(params.profile, params.provider, params.model)
if (currentContextLengthKey() !== key) return
contextLength.value = value
contextLengthLoadedKey = key
} catch {
if (currentContextLengthKey() !== key) return
contextLength.value = FALLBACK_CONTEXT
contextLengthLoadedKey = key
} finally {
if (contextLengthRequestKey === key) {
contextLengthRequest = null
contextLengthRequestKey = ''
}
}
})()
return contextLengthRequest
}
onMounted(loadContextLength)
watch(
() => [
profilesStore.activeProfileName,
appStore.selectedProvider,
appStore.selectedModel,
chatStore.activeSession?.id,
chatStore.activeSession?.profile,
chatStore.activeSession?.provider,
chatStore.activeSession?.model,
chatStore.activeSession?.source,
],
loadContextLength,
{ flush: 'post' },
)
const totalTokens = computed(() => {
const context = chatStore.activeSession?.contextTokens
if (typeof context === 'number' && Number.isFinite(context) && context > 0) return context
const input = chatStore.activeSession?.inputTokens ?? 0
const output = chatStore.activeSession?.outputTokens ?? 0
return input + output
})
const showContextUsage = computed(() => !!chatStore.activeSession)
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 addFiles(files: File[]) {
for (const file of files) addFile(file)
if (files.length > 0) textareaRef.value?.focus()
}
function handleAttachClick() {
fileInputRef.value?.click()
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
addFiles(Array.from(input.files))
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 })
addFiles([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
addFiles(files)
}
defineExpose({ addFiles })
// --- Send ---
function handleSend() {
const text = inputText.value.trim()
if (!text && attachments.value.length === 0) return
if (isBridgeSession.value && text === '/skill' && attachments.value.length === 0) {
void openSkillPicker()
return
}
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
inputText.value = ''
saveDraftForActiveSession('')
attachments.value = []
slashActive.value = false
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
}
}
async function startVoiceCapture() {
browserRecognition.clearError()
const { captureId } = await voiceDialogue.beginCapture()
const useBrowserProvider = sttSettings.provider.value === 'browser'
activeVoiceCaptureMode.value = useBrowserProvider ? 'browser' : 'backend'
try {
if (useBrowserProvider) {
await browserRecognition.start({ language: browserCaptureLanguage() })
return
}
await micRecorder.start()
} catch {
activeVoiceCaptureMode.value = null
voiceDialogue.cancelCapture(captureId)
}
}
async function stopVoiceCapture() {
const captureId = voiceDialogue.activeCaptureId.value
if (!captureId) return
if (activeVoiceCaptureMode.value === 'browser') {
let transcript = ''
try {
transcript = await browserRecognition.stop()
} catch {
activeVoiceCaptureMode.value = null
voiceDialogue.cancelCapture(captureId)
return
}
activeVoiceCaptureMode.value = null
try {
await voiceDialogue.commitTranscript(captureId, transcript)
} catch {
// Voice dialogue state already tracks send errors.
}
return
}
if (micRecorder.state.value.status === 'requesting') {
micRecorder.cancel()
activeVoiceCaptureMode.value = null
voiceDialogue.cancelCapture(captureId)
return
}
let audio: Blob
try {
audio = await micRecorder.stop()
} catch {
activeVoiceCaptureMode.value = null
voiceDialogue.cancelCapture(captureId)
return
}
activeVoiceCaptureMode.value = null
if (audio.size <= 0) {
voiceDialogue.cancelCapture(captureId)
return
}
try {
await voiceDialogue.transcribeAndSend(captureId, audio)
} catch {
// Voice dialogue state already tracks transcription/send errors.
}
}
function cancelVoiceCapture() {
if (activeVoiceCaptureMode.value === 'browser') {
browserRecognition.cancel()
} else {
micRecorder.cancel()
}
activeVoiceCaptureMode.value = null
voiceDialogue.cancelCapture()
}
function handleCompositionStart() {
isComposing.value = true
}
function handleCompositionEnd() {
requestAnimationFrame(() => {
isComposing.value = false
updateSlashState()
})
}
function isImeEnter(e: KeyboardEvent): boolean {
return isComposing.value || e.isComposing || e.keyCode === 229
}
function handleKeydown(e: KeyboardEvent) {
if (slashActive.value && filteredBridgeCommands.value.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
slashActiveIndex.value = (slashActiveIndex.value + 1) % filteredBridgeCommands.value.length
scrollCommandIntoView()
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
slashActiveIndex.value = (slashActiveIndex.value - 1 + filteredBridgeCommands.value.length) % filteredBridgeCommands.value.length
scrollCommandIntoView()
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
selectBridgeCommand(filteredBridgeCommands.value[slashActiveIndex.value])
return
}
if (e.key === 'Escape') {
e.preventDefault()
slashActive.value = false
return
}
}
if (e.key !== 'Enter' || e.shiftKey) return
if (isImeEnter(e)) return
e.preventDefault()
handleSend()
}
function handleInput(e: Event) {
const el = e.target as HTMLTextAreaElement
if (!isComposing.value) updateSlashState()
// 用户手动拖拽自定义高度时,不覆盖
if (textareaHeight.value !== null) return
autoSizeTextarea(el)
}
function handleCommandHover(index: number) {
slashActiveIndex.value = index
}
function onDocumentMousedown(e: MouseEvent) {
if (!slashActive.value) return
const target = e.target as HTMLElement
if (!target.closest('.slash-command-dropdown') && !target.closest('.input-wrapper')) {
slashActive.value = false
}
}
onMounted(() => {
document.addEventListener('mousedown', onDocumentMousedown)
})
onUnmounted(() => {
document.removeEventListener('mousedown', onDocumentMousedown)
window.removeEventListener('resize', syncViewport)
})
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">
<!-- 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 }"
:style="inputWrapperStyle"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDrop"
@mousedown="focusTextareaFromInputWrapper"
>
<input
ref="fileInputRef"
type="file"
multiple
class="file-input-hidden"
@change="handleFileChange"
/>
<div
class="resize-handle"
:title="t('chat.inputHeightResizeHint')"
@mousedown="startResize"
@dblclick="resetTextareaHeight"
></div>
<div v-if="showContextUsage" class="context-usage-row">
<span class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
{{ formatTokens(totalTokens) }} /
<a-tooltip trigger="hover" :disabled="isMobileViewport" :content="t('chat.contextClickToEdit')">
<span class="context-limit-editable" @click="handleEditContextLimit">
{{ formatTokens(contextLength) }}
</span>
</a-tooltip>
· {{ t('chat.contextRemaining') }} {{ formatTokens(remainingTokens) }}
</span>
<div 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>
<textarea
ref="textareaRef"
v-model="inputText"
class="input-textarea"
:style="textareaHeight ? { height: textareaHeight + 'px' } : {}"
:placeholder="t('chat.inputPlaceholder')"
rows="1"
@keydown="handleKeydown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@input="handleInput"
@paste="handlePaste"
></textarea>
<div class="input-toolbar">
<!-- Bottom bar: attach + input settings + actions -->
<div class="input-top-bar">
<a-tooltip
trigger="hover"
:disabled="isMobileViewport"
:content="t('chat.attachFiles')"
>
<a-button type="secondary" size="mini" shape="circle" @click="handleAttachClick" class="toolbar-icon-button">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</template>
</a-button>
</a-tooltip>
<a-dropdown
trigger="click"
@select="onReasoningEffortChange"
>
<a-button
type="secondary"
size="mini"
class="reasoning-effort-button"
:class="{ active: !!currentReasoningEffort }"
:title="isMobileViewport ? undefined : `${t('chat.reasoningEffort.tooltip')}: ${reasoningEffortLabel}`"
:aria-label="`${t('chat.reasoningEffort.tooltip')}: ${reasoningEffortLabel}`"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 2.5 2.5 0 0 0-.34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/>
</svg>
</template>
<span class="reasoning-effort-label">{{ reasoningEffortLabel }}</span>
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</a-button>
<template #content>
<a-doption
v-for="option in reasoningEffortOptions"
:key="option.value"
:value="option.value"
:active="option.value === currentReasoningEffort"
>
{{ option.label }}
</a-doption>
</template>
</a-dropdown>
<a-dropdown
trigger="click"
@select="handleInputSettingsSelect"
>
<a-button
type="secondary"
size="mini"
class="input-settings-button"
:title="isMobileViewport ? undefined : t('sidebar.settings')"
:aria-label="t('sidebar.settings')"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.08a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.2.6.77 1 1.4 1H21a2 2 0 1 1 0 4h-.09c-.63 0-1.2.4-1.51 1Z"/>
</svg>
</template>
<span class="input-settings-label">{{ t('sidebar.settings') }}</span>
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</a-button>
<template #content>
<a-doption
v-for="option in inputSettingsOptions"
:key="option.key"
:value="option.key"
>
<span class="settings-option">
<component :is="option.icon" />
{{ option.label }}
</span>
</a-doption>
</template>
</a-dropdown>
<a-tooltip
trigger="hover"
:disabled="isMobileViewport"
:content="props.modelLabel || t('models.selectModel')"
>
<a-button
type="secondary"
size="mini"
class="input-model-button"
:title="isMobileViewport ? undefined : props.modelLabel || t('models.selectModel')"
:aria-label="props.modelLabel || t('models.selectModel')"
@click="handleModelButtonClick"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4" />
<path d="M12 19v4" />
<path d="M1 12h4" />
<path d="M19 12h4" />
<path d="M4.22 4.22l2.83 2.83" />
<path d="M16.95 16.95l2.83 2.83" />
<path d="M4.22 19.78l2.83-2.83" />
<path d="M16.95 7.05l2.83-2.83" />
</svg>
</template>
<span class="input-model-label">{{ compactModelLabel }}</span>
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</a-button>
</a-tooltip>
</div>
<div class="input-actions">
<VoiceDialogueControls
:status="voiceDialogue.status.value"
:transcript="voiceDialogueTranscript"
:error="voiceDialogueError"
:events="voiceDialogue.events.value"
:on-start="startVoiceCapture"
:on-stop="stopVoiceCapture"
:on-cancel="cancelVoiceCapture"
/>
<a-button
size="medium"
type="primary"
shape="circle"
class="send-button"
:class="{ 'send-button--stop': sendButtonIsStop }"
:disabled="sendButtonIsStop ? chatStore.isAborting : !canSend"
:aria-label="sendButtonIsStop ? 'Stop' : 'Send'"
@click="sendButtonIsStop ? chatStore.stopStreaming() : handleSend()"
>
<template #icon>
<svg
v-if="sendButtonIsStop"
width="18"
height="18"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" />
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg>
</template>
<span class="visually-hidden">{{ sendButtonIsStop ? t('chat.stop') : t('chat.send') }}</span>
</a-button>
</div>
</div>
<Transition name="dropdown-fade">
<div
v-if="slashActive && filteredBridgeCommands.length > 0"
ref="commandDropdownRef"
class="slash-command-dropdown"
>
<div
v-for="(command, i) in filteredBridgeCommands"
:key="command.key"
class="slash-command-item"
:class="{ active: i === slashActiveIndex }"
@mousedown.prevent="selectBridgeCommand(command)"
@mouseenter="handleCommandHover(i)"
>
<span class="slash-command-name">/{{ command.name }}</span>
<span v-if="command.args" class="slash-command-args">{{ command.args }}</span>
<span class="slash-command-desc">{{ command.description }}</span>
</div>
</div>
</Transition>
</div>
<a-modal
v-model:visible="showSkillPicker"
:title="t('skills.title')"
:mask-closable="true"
width="min(620px, calc(100vw - 32px))"
:footer="false"
unmount-on-close
>
<div v-if="showSkillPicker" class="skill-picker-modal">
<input
v-model="skillSearch"
class="skill-picker-search"
:placeholder="t('skills.searchPlaceholder')"
type="search"
/>
<div class="skill-picker-list">
<div v-if="skillPickerLoading" class="skill-picker-empty">
{{ t('common.loading') }}
</div>
<template v-else>
<div
v-for="skill in filteredSkillPickerItems"
:key="skill.key"
role="button"
tabindex="0"
class="skill-picker-item"
@click="selectSkill(skill)"
@keydown.enter.prevent="selectSkill(skill)"
@keydown.space.prevent="selectSkill(skill)"
>
<div class="skill-picker-command">/skill {{ skill.commandName }}</div>
<div class="skill-picker-name">{{ skill.name }}</div>
<div class="skill-picker-desc">{{ skill.description }}</div>
</div>
</template>
<div v-if="!skillPickerLoading && filteredSkillPickerItems.length === 0" class="skill-picker-empty">
{{ skillSearch ? t('skills.noMatch') : t('skills.noSkills') }}
</div>
</div>
</div>
</a-modal>
<!-- Context Length Edit Modal -->
<a-modal
v-model:visible="showContextEditModal"
:title="t('chat.contextEditTitle')"
:mask-closable="true"
:width="400"
unmount-on-close
>
<div class="context-edit-content">
<p style="margin-bottom: 16px; color: #666;">
{{ t('chat.contextEditDesc') }}
</p>
<a-input-number
v-model="editingContextLimit"
:min="1000"
:max="10000000"
:step="1000"
hide-button
:placeholder="t('chat.contextEditPlaceholder')"
style="width: 100%"
>
<template #suffix>
<span style="color: #999;">tokens</span>
</template>
</a-input-number>
<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;">
<a-button @click="showContextEditModal = false" :disabled="isSavingContextLimit">
{{ t('chat.contextEditCancel') }}
</a-button>
<a-button type="primary" @click="saveContextLimit" :loading="isSavingContextLimit">
{{ t('chat.contextEditSave') }}
</a-button>
</div>
</template>
</a-modal>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.chat-input-area {
position: relative;
z-index: 80;
padding: 8px 20px 14px;
border-top: 0;
background-color: $bg-card;
flex-shrink: 0;
.dark & {
background-color: #333333;
}
}
.input-top-bar {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
flex: 1;
padding: 0;
}
.auto-play-speech-switch {
display: flex;
align-items: center;
gap: 5px;
padding: 0 0 0 2px;
margin-left: 0;
.switch-label {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #999999;
font-size: 12px;
svg {
opacity: 1;
}
}
:deep(.arco-switch),
:deep(.arco-switch-track) {
margin-right: 0;
}
}
.tool-trace-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
color: #999999;
width: 24px;
min-width: 24px;
height: 22px;
margin-left: 0;
padding: 0;
background: transparent !important;
opacity: 1;
.tool-trace-icon {
display: block;
flex: 0 0 16px;
width: 16px;
height: 16px;
}
&.active {
color: #999999;
opacity: 1;
}
&:hover {
color: #999999;
opacity: 1;
}
}
.input-settings-button {
color: $text-secondary;
border-radius: 999px;
padding: 0 7px 0 6px;
gap: 4px;
border-color: transparent;
}
.input-model-button {
color: $text-secondary;
border-radius: 999px;
max-width: 190px;
padding: 0 4px 0 6px;
gap: 4px;
border-color: transparent;
}
.input-model-label {
display: inline-block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
white-space: nowrap;
}
.reasoning-effort-button {
color: $text-secondary;
border-radius: 999px;
padding: 0 4px 0 6px;
gap: 4px;
border-color: transparent;
&.active {
color: #4caf50;
}
}
.reasoning-effort-label {
display: inline-block;
max-width: 96px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
white-space: nowrap;
}
.toolbar-chevron {
flex: 0 0 12px;
color: $text-muted;
}
.context-usage-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 7px;
position: absolute;
top: 9px;
right: 14px;
z-index: 1;
min-width: 0;
max-width: calc(100% - 28px);
padding: 0;
pointer-events: auto;
}
.context-info {
font-size: 11px;
color: $text-muted;
min-width: 0;
white-space: nowrap;
&.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;
margin-left: -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);
}
}
.dark .context-info {
color: rgba(255, 255, 255, 0.68);
&.context-warning {
color: #f0bc58;
}
}
.dark .context-limit-editable {
color: rgba(255, 255, 255, 0.8);
&:hover {
border-bottom-color: rgba(255, 255, 255, 0.58);
background: rgba(255, 255, 255, 0.08);
}
}
.dark .context-bar {
background: rgba(255, 255, 255, 0.18);
}
.dark .context-bar-fill {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.72));
&.context-bar-warn {
background: linear-gradient(90deg, #d99d35, #f0bc58);
}
&.context-bar-danger {
background: linear-gradient(90deg, #d95445, #ff7a68);
}
}
@media (max-width: 768px) {
.chat-input-area {
--voice-overlay-mobile-bottom-offset: 146px;
padding: 8px 12px 12px;
}
.input-top-bar {
gap: 5px;
}
.reasoning-effort-label,
.auto-play-speech-switch {
display: none;
}
.input-model-button {
min-width: 35px;
max-width: 35px;
padding: 0 4px 0 6px;
}
.input-model-label {
display: none;
}
.input-settings-button {
min-width: 36px;
padding: 0 4px 0 6px;
gap: 2px;
}
.input-settings-label {
display: none;
}
.context-info {
overflow: hidden;
text-overflow: ellipsis;
font-size: 10px;
line-height: 14px;
}
.context-bar {
width: 42px;
flex-shrink: 0;
}
}
.attachment-previews {
width: 100%;
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;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: 100%;
min-height: 150px;
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: 18px;
padding: 22px 12px 9px;
position: relative;
cursor: text;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.08);
transition: border-color $transition-fast, box-shadow $transition-fast;
&:focus-within {
border-color: rgba(var(--text-primary-rgb), 0.22);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.11);
}
&.drag-over {
border-color: $accent-primary;
background-color: rgba(var(--accent-primary-rgb), 0.04);
}
.dark & {
background-color: #333333;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);
}
}
.resize-handle {
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 8px;
cursor: row-resize;
z-index: 2;
&:hover {
background: rgba($accent-primary, 0.15);
border-radius: 4px;
}
}
.input-textarea {
display: block;
flex: 1 1 auto;
width: 100%;
background: none;
border: none;
outline: none;
color: $text-primary;
font-family: $font-ui;
font-size: 14px;
line-height: 1.5;
resize: none;
max-height: 400px;
min-height: 44px;
padding: 0;
overflow-y: auto;
@media (max-width: 768px) {
font-size: 16px;
}
&::placeholder {
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.input-actions {
display: flex;
gap: 7px;
flex-shrink: 0;
align-items: center;
}
.input-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: auto;
min-height: 32px;
flex-wrap: nowrap;
}
.toolbar-icon-button {
color: $text-muted;
}
.send-button {
width: 30px !important;
min-width: 30px !important;
height: 30px !important;
padding: 0 !important;
border: 0 !important;
box-shadow: none !important;
gap: 0;
:deep(.arco-btn-icon) {
margin: 0 !important;
}
&:disabled {
color: var(--text-on-overlay);
background-color: #9f9f9f;
opacity: 1;
}
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.slash-command-dropdown {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(100% + 8px);
max-height: 240px;
overflow-y: auto;
background: $bg-primary;
border: 1px solid $border-color;
border-radius: $radius-sm;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
z-index: 20;
padding: 4px;
.dark & {
background: #2a2a2a;
}
}
.slash-command-item {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: $radius-sm;
cursor: pointer;
min-height: 36px;
&.active,
&:hover {
background: rgba(var(--accent-primary-rgb), 0.1);
}
}
.slash-command-name {
font-family: $font-code;
font-size: 13px;
color: $accent-primary;
white-space: nowrap;
}
.slash-command-args {
font-family: $font-code;
font-size: 12px;
color: $text-muted;
white-space: nowrap;
}
.slash-command-desc {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-secondary;
font-size: 12px;
}
.skill-picker-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.skill-picker-search {
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid $border-color;
border-radius: $radius-sm;
background: $bg-input;
color: $text-primary;
outline: none;
font-family: $font-ui;
font-size: 13px;
&:focus {
border-color: $accent-primary;
}
}
.skill-picker-list {
max-height: min(420px, 52vh);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.skill-picker-item {
display: block;
flex: 0 0 76px;
width: 100%;
height: 76px;
box-sizing: border-box;
padding: 7px 10px;
border: 1px solid $border-color;
border-radius: $radius-sm;
background: $bg-secondary;
color: $text-primary;
text-align: left;
cursor: pointer;
overflow: hidden;
outline: none;
&:focus-visible,
&:hover {
border-color: rgba(var(--accent-primary-rgb), 0.5);
background: rgba(var(--accent-primary-rgb), 0.08);
}
}
.skill-picker-command {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-family: $font-code;
font-size: 12px;
line-height: 16px;
color: $accent-primary;
white-space: nowrap;
}
.skill-picker-name,
.skill-picker-desc {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-picker-name {
margin-top: 3px;
font-size: 13px;
line-height: 18px;
color: $text-primary;
}
.skill-picker-desc {
margin-top: 3px;
font-size: 12px;
line-height: 16px;
color: $text-secondary;
}
.skill-picker-empty {
padding: 18px 10px;
text-align: center;
color: $text-muted;
font-size: 13px;
}
@media (max-width: 768px) {
.skill-picker-item {
height: 76px;
}
.input-wrapper {
min-height: 118px;
}
.input-textarea::placeholder {
font-size: 13px;
line-height: 1.35;
}
}
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
transition: opacity 0.12s ease, transform 0.12s ease;
}
.dropdown-fade-enter-from,
.dropdown-fade-leave-to {
opacity: 0;
transform: translateY(4px);
}
</style>