ComposerShell.vue 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. <script setup lang="ts">
  2. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  3. import AppIcon from '@/components/common/AppIcon.vue'
  4. import { createId } from '@/utils/id'
  5. import type { ComposerAttachment, ComposerOption, ComposerReference, ComposerSubmit } from './types'
  6. const props = withDefaults(defineProps<{
  7. mode?: 'chat' | 'group'
  8. draftKey?: string
  9. placeholder?: string
  10. disabled?: boolean
  11. streaming?: boolean
  12. stopWhileRunning?: boolean
  13. sending?: boolean
  14. modelLabel?: string
  15. fastMode?: boolean
  16. fastModeDisabled?: boolean
  17. reasoningEffort?: string
  18. reasoningValue?: string
  19. reasoningOptions?: ComposerOption[]
  20. contextUsed?: number
  21. contextLimit?: number
  22. contextEstimated?: boolean
  23. queueMode?: boolean
  24. toolTraceVisible?: boolean
  25. reference?: ComposerReference | null
  26. mentionOptions?: ComposerOption[]
  27. slashCommands?: ComposerOption[]
  28. attachmentsEnabled?: boolean
  29. activityText?: string
  30. }>(), {
  31. mode: 'chat',
  32. draftKey: '',
  33. placeholder: '输入消息,Enter 发送,Shift + Enter 换行',
  34. disabled: false,
  35. streaming: false,
  36. stopWhileRunning: false,
  37. sending: false,
  38. modelLabel: '选择模型',
  39. fastMode: false,
  40. fastModeDisabled: false,
  41. reasoningEffort: '默认',
  42. reasoningValue: '',
  43. reasoningOptions: () => [],
  44. contextUsed: 0,
  45. contextLimit: 0,
  46. contextEstimated: false,
  47. queueMode: false,
  48. toolTraceVisible: true,
  49. reference: null,
  50. mentionOptions: () => [],
  51. slashCommands: () => [],
  52. attachmentsEnabled: true,
  53. activityText: '',
  54. })
  55. const emit = defineEmits<{
  56. send: [payload: ComposerSubmit]
  57. stop: []
  58. modelClick: []
  59. fastModeToggle: [enabled: boolean]
  60. reasoningChange: [value: string]
  61. settingsClick: []
  62. queueToggle: []
  63. toolTraceToggle: []
  64. clearReference: []
  65. error: [message: string]
  66. }>()
  67. const MAX_FILE_SIZE = 25 * 1024 * 1024
  68. const MAX_FILES = 8
  69. const text = ref('')
  70. const textarea = ref<HTMLTextAreaElement | null>(null)
  71. const fileInput = ref<HTMLInputElement | null>(null)
  72. const attachments = ref<ComposerAttachment[]>([])
  73. const isComposing = ref(false)
  74. const dragDepth = ref(0)
  75. const dragOver = computed(() => dragDepth.value > 0)
  76. const manualHeight = ref<number | null>(null)
  77. const menuIndex = ref(0)
  78. const menuKind = ref<'slash' | 'mention' | null>(null)
  79. const menuQuery = ref('')
  80. const chosenMentionIds = ref<string[]>([])
  81. const reasoningOpen = ref(false)
  82. const settingsOpen = ref(false)
  83. const localError = ref('')
  84. let errorTimer: number | undefined
  85. const canSubmit = computed(() => !props.disabled && !props.sending && (text.value.trim().length > 0 || attachments.value.length > 0))
  86. // A running group still accepts new room messages. Only an ordinary session
  87. // turns its primary send affordance into an interrupt control.
  88. const showStop = computed(() => props.streaming && !text.value.trim() && !attachments.value.length && (props.stopWhileRunning || props.mode === 'chat'))
  89. const compactModel = computed(() => props.modelLabel.split('/').filter(Boolean).at(-1) || props.modelLabel)
  90. const contextPercent = computed(() => props.contextLimit > 0 ? Math.min(100, Math.round(props.contextUsed / props.contextLimit * 100)) : 0)
  91. const hasContext = computed(() => props.contextLimit > 0)
  92. const remainingContextTokens = computed(() => Math.max(0, props.contextLimit - props.contextUsed))
  93. const reasoningOptionIndex = computed(() => Math.max(0, props.reasoningOptions.findIndex(option => option.id === props.reasoningValue)))
  94. const reasoningLabel = computed(() => props.reasoningOptions[reasoningOptionIndex.value]?.label || props.reasoningEffort)
  95. const menuOptions = computed(() => {
  96. const source = menuKind.value === 'slash' ? props.slashCommands : props.mentionOptions
  97. const query = menuQuery.value.trim().toLocaleLowerCase()
  98. return source.filter(option => {
  99. if (!query) return true
  100. return option.label.toLocaleLowerCase().includes(query) || option.detail?.toLocaleLowerCase().includes(query)
  101. }).slice(0, 10)
  102. })
  103. function draftStorageKey(key = props.draftKey) {
  104. return key ? `hermes-yaoyao:composer:${props.mode}:${key}` : ''
  105. }
  106. function restoreDraft() {
  107. const key = draftStorageKey()
  108. text.value = key ? localStorage.getItem(key) || '' : ''
  109. nextTick(autoSize)
  110. }
  111. function persistDraft(value: string) {
  112. const key = draftStorageKey()
  113. if (!key) return
  114. if (value) localStorage.setItem(key, value)
  115. else localStorage.removeItem(key)
  116. }
  117. function showError(message: string) {
  118. localError.value = message
  119. emit('error', message)
  120. window.clearTimeout(errorTimer)
  121. errorTimer = window.setTimeout(() => { localError.value = '' }, 4500)
  122. }
  123. function formatSize(bytes: number) {
  124. if (bytes < 1024) return `${bytes} B`
  125. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
  126. return `${(bytes / 1024 / 1024).toFixed(1)} MB`
  127. }
  128. function isImage(type: string) { return type.startsWith('image/') }
  129. function addFiles(files: File[]) {
  130. if (!props.attachmentsEnabled) {
  131. showError('当前上游不支持团队附件')
  132. return
  133. }
  134. const available = MAX_FILES - attachments.value.length
  135. if (available <= 0) return showError(`最多添加 ${MAX_FILES} 个文件`)
  136. const accepted: ComposerAttachment[] = []
  137. for (const file of files.slice(0, available)) {
  138. if (file.size > MAX_FILE_SIZE) {
  139. showError(`${file.name} 超过 25 MiB`)
  140. continue
  141. }
  142. accepted.push({
  143. id: `${file.name}:${file.size}:${file.lastModified}:${createId('attachment')}`,
  144. file,
  145. name: file.name,
  146. size: file.size,
  147. type: file.type || 'application/octet-stream',
  148. previewUrl: isImage(file.type) ? URL.createObjectURL(file) : undefined,
  149. })
  150. }
  151. attachments.value.push(...accepted)
  152. if (files.length > available) showError(`最多添加 ${MAX_FILES} 个文件`)
  153. }
  154. function removeAttachment(id: string) {
  155. const index = attachments.value.findIndex(item => item.id === id)
  156. if (index < 0) return
  157. const [removed] = attachments.value.splice(index, 1)
  158. if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl)
  159. }
  160. function clearAttachments() {
  161. attachments.value.forEach(item => item.previewUrl && URL.revokeObjectURL(item.previewUrl))
  162. attachments.value = []
  163. if (fileInput.value) fileInput.value.value = ''
  164. }
  165. function onFiles(event: Event) {
  166. const input = event.target as HTMLInputElement
  167. addFiles(Array.from(input.files ?? []))
  168. input.value = ''
  169. }
  170. function onPaste(event: ClipboardEvent) {
  171. const files = Array.from(event.clipboardData?.files ?? [])
  172. if (files.length) addFiles(files)
  173. }
  174. function onDragEnter(event: DragEvent) {
  175. if (!event.dataTransfer?.types.includes('Files')) return
  176. event.preventDefault()
  177. dragDepth.value += 1
  178. }
  179. function onDragOver(event: DragEvent) {
  180. if (!event.dataTransfer?.types.includes('Files')) return
  181. event.preventDefault()
  182. if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
  183. }
  184. function onDragLeave(event: DragEvent) {
  185. event.preventDefault()
  186. dragDepth.value = Math.max(0, dragDepth.value - 1)
  187. }
  188. function onDrop(event: DragEvent) {
  189. event.preventDefault()
  190. dragDepth.value = 0
  191. addFiles(Array.from(event.dataTransfer?.files ?? []))
  192. }
  193. function autoSize() {
  194. const el = textarea.value
  195. if (!el || manualHeight.value !== null) return
  196. el.style.height = 'auto'
  197. el.style.height = `${Math.min(400, Math.max(30, el.scrollHeight))}px`
  198. }
  199. function resetHeight() {
  200. manualHeight.value = null
  201. if (textarea.value) textarea.value.style.height = 'auto'
  202. nextTick(autoSize)
  203. }
  204. function startResize(event: PointerEvent) {
  205. const el = textarea.value
  206. if (!el) return
  207. event.preventDefault()
  208. const startY = event.clientY
  209. const startHeight = el.clientHeight
  210. const pointerId = event.pointerId
  211. const handle = event.currentTarget as HTMLElement
  212. handle.setPointerCapture(pointerId)
  213. const move = (moveEvent: PointerEvent) => {
  214. manualHeight.value = Math.min(400, Math.max(30, startHeight - (moveEvent.clientY - startY)))
  215. }
  216. const up = () => {
  217. handle.removeEventListener('pointermove', move)
  218. handle.removeEventListener('pointerup', up)
  219. handle.removeEventListener('pointercancel', up)
  220. }
  221. handle.addEventListener('pointermove', move)
  222. handle.addEventListener('pointerup', up)
  223. handle.addEventListener('pointercancel', up)
  224. }
  225. function updateMenu() {
  226. const el = textarea.value
  227. if (!el || isComposing.value) return
  228. const before = text.value.slice(0, el.selectionStart)
  229. if (before.startsWith('/') && !before.includes(' ') && !before.includes('\n') && props.slashCommands.length) {
  230. menuKind.value = 'slash'
  231. menuQuery.value = before.slice(1)
  232. menuIndex.value = 0
  233. return
  234. }
  235. if (props.mode === 'group' && props.mentionOptions.length) {
  236. const match = before.match(/(?<![A-Za-z0-9_.%+/@-])@([^\s@]*)$/u)
  237. if (match && !/https?:\/\/\S*$/u.test(before)) {
  238. menuKind.value = 'mention'
  239. menuQuery.value = match[1] ?? ''
  240. menuIndex.value = 0
  241. return
  242. }
  243. }
  244. menuKind.value = null
  245. }
  246. function selectOption(option: ComposerOption) {
  247. if (option.disabled) return
  248. const el = textarea.value
  249. if (!el) return
  250. const cursor = el.selectionStart
  251. const before = text.value.slice(0, cursor)
  252. const after = text.value.slice(cursor)
  253. if (menuKind.value === 'slash') {
  254. const replacement = option.insertText ?? `/${option.label} `
  255. text.value = replacement + after
  256. nextTick(() => el.setSelectionRange(replacement.length, replacement.length))
  257. } else {
  258. const match = before.match(/(?<![A-Za-z0-9_.%+/@-])@([^\s@]*)$/u)
  259. const start = match ? cursor - match[0].length : cursor
  260. const prefix = text.value.slice(0, start)
  261. const replacement = `@${option.label} `
  262. text.value = prefix + replacement + after
  263. if (option.id !== 'all' && !chosenMentionIds.value.includes(option.id)) chosenMentionIds.value.push(option.id)
  264. nextTick(() => {
  265. const pos = prefix.length + replacement.length
  266. el.setSelectionRange(pos, pos)
  267. })
  268. }
  269. menuKind.value = null
  270. nextTick(() => { el.focus(); autoSize() })
  271. }
  272. function handleKeydown(event: KeyboardEvent) {
  273. if (menuKind.value && menuOptions.value.length) {
  274. if (event.key === 'ArrowDown') {
  275. event.preventDefault(); menuIndex.value = (menuIndex.value + 1) % menuOptions.value.length; return
  276. }
  277. if (event.key === 'ArrowUp') {
  278. event.preventDefault(); menuIndex.value = (menuIndex.value - 1 + menuOptions.value.length) % menuOptions.value.length; return
  279. }
  280. if (event.key === 'Escape') { event.preventDefault(); menuKind.value = null; return }
  281. if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {
  282. event.preventDefault()
  283. const option = menuOptions.value[menuIndex.value]
  284. if (option) selectOption(option)
  285. return
  286. }
  287. }
  288. if (event.key !== 'Enter' || event.shiftKey) return
  289. if (isComposing.value || event.isComposing || event.keyCode === 229) return
  290. event.preventDefault()
  291. submit()
  292. }
  293. function endComposition() {
  294. requestAnimationFrame(() => {
  295. isComposing.value = false
  296. updateMenu()
  297. })
  298. }
  299. function submit() {
  300. if (!canSubmit.value) return
  301. const payload: ComposerSubmit = {
  302. text: text.value.trim(),
  303. files: attachments.value.map(item => item.file),
  304. mentionIds: [...chosenMentionIds.value],
  305. }
  306. emit('send', payload)
  307. nextTick(() => textarea.value?.focus())
  308. }
  309. function clearAfterSend() {
  310. text.value = ''
  311. chosenMentionIds.value = []
  312. persistDraft('')
  313. clearAttachments()
  314. resetHeight()
  315. nextTick(() => textarea.value?.focus())
  316. }
  317. function onWrapperMouseDown(event: MouseEvent) {
  318. const target = event.target as HTMLElement
  319. if (target.closest('button, input, textarea, [role="option"], .composer-resize')) return
  320. textarea.value?.focus()
  321. }
  322. function formatTokens(value: number) {
  323. if (value >= 1_048_576) return `${(value / 1_048_576).toFixed(1)}m`
  324. if (value >= 1024) return `${(value / 1024).toFixed(1)}k`
  325. return `${value}`
  326. }
  327. function selectReasoningByIndex(event: Event) {
  328. const index = Number((event.target as HTMLInputElement).value)
  329. const option = props.reasoningOptions[index]
  330. if (option) emit('reasoningChange', option.id)
  331. }
  332. function onDocumentPointer(event: PointerEvent) {
  333. const target = event.target as HTMLElement
  334. if (!target.closest('.composer-shell')) {
  335. menuKind.value = null
  336. reasoningOpen.value = false
  337. settingsOpen.value = false
  338. }
  339. }
  340. watch(text, value => { persistDraft(value); nextTick(autoSize) })
  341. watch(() => props.draftKey, () => {
  342. restoreDraft()
  343. reasoningOpen.value = false
  344. settingsOpen.value = false
  345. })
  346. onMounted(() => {
  347. restoreDraft()
  348. document.addEventListener('pointerdown', onDocumentPointer)
  349. })
  350. onBeforeUnmount(() => {
  351. clearAttachments()
  352. window.clearTimeout(errorTimer)
  353. document.removeEventListener('pointerdown', onDocumentPointer)
  354. })
  355. defineExpose({
  356. attachFiles: addFiles,
  357. filesSnapshot: () => attachments.value.map(attachment => attachment.file),
  358. focus: () => textarea.value?.focus(),
  359. clearAfterSend,
  360. })
  361. </script>
  362. <template>
  363. <div class="composer-area">
  364. <div v-if="attachments.length" class="composer-attachments" aria-label="附件">
  365. <div v-for="attachment in attachments" :key="attachment.id" class="composer-attachment" :class="{ image: isImage(attachment.type) }">
  366. <img v-if="attachment.previewUrl" :src="attachment.previewUrl" :alt="attachment.name" />
  367. <AppIcon v-else name="file" :size="19" />
  368. <span><strong>{{ attachment.name }}</strong><small>{{ formatSize(attachment.size) }}</small></span>
  369. <button type="button" :aria-label="`移除 ${attachment.name}`" @click="removeAttachment(attachment.id)"><AppIcon name="close" :size="12" /></button>
  370. </div>
  371. </div>
  372. <div v-if="reference" class="composer-reference">
  373. <AppIcon name="quote" :size="14" />
  374. <span><small>{{ reference.author ? `回复 ${reference.author}` : '回复消息' }}</small><strong>{{ reference.content.replace(/\s+/g, ' ').trim() }}</strong></span>
  375. <button type="button" aria-label="取消引用" @click="emit('clearReference')"><AppIcon name="close" :size="13" /></button>
  376. </div>
  377. <div v-if="mode === 'group'" class="composer-activity-slot">
  378. <Transition name="composer-activity">
  379. <div v-if="activityText" class="composer-activity" role="status" aria-label="机器人输入状态" aria-live="polite" aria-atomic="true">
  380. <span class="composer-typing-dots" aria-hidden="true"><i /><i /><i /></span>
  381. <strong>{{ activityText }}</strong>
  382. </div>
  383. </Transition>
  384. </div>
  385. <slot name="before-input" />
  386. <div
  387. class="composer-shell"
  388. :class="{ 'composer-shell--drag': dragOver, 'composer-shell--disabled': disabled }"
  389. @mousedown="onWrapperMouseDown"
  390. @dragenter="onDragEnter"
  391. @dragover="onDragOver"
  392. @dragleave="onDragLeave"
  393. @drop="onDrop"
  394. >
  395. <input ref="fileInput" class="composer-file-input" type="file" multiple @change="onFiles" />
  396. <div class="composer-resize" title="拖动调整高度;双击复位" @pointerdown="startResize" @dblclick="resetHeight" />
  397. <div v-if="hasContext" class="composer-context" :class="{ warning: contextPercent > 80 }" :title="contextEstimated ? 'Hermes 未提供实时上下文用量;此数值按当前会话内容估算。' : undefined">
  398. <span>{{ contextEstimated ? '约 ' : '' }}{{ formatTokens(contextUsed) }} / {{ formatTokens(contextLimit) }} · 剩余 {{ formatTokens(remainingContextTokens) }}</span>
  399. <i><b :style="{ width: `${contextPercent}%` }" /></i>
  400. </div>
  401. <textarea
  402. ref="textarea"
  403. v-model="text"
  404. class="composer-textarea"
  405. :style="manualHeight !== null ? { height: `${manualHeight}px` } : undefined"
  406. rows="1"
  407. :disabled="disabled"
  408. :placeholder="placeholder"
  409. @input="updateMenu"
  410. @click="updateMenu"
  411. @keydown="handleKeydown"
  412. @paste="onPaste"
  413. @compositionstart="isComposing = true"
  414. @compositionend="endComposition"
  415. />
  416. <div class="composer-toolbar">
  417. <div class="composer-tools">
  418. <button class="composer-tool composer-tool--icon" type="button" :disabled="disabled || !attachmentsEnabled" :title="attachmentsEnabled ? '添加附件' : '当前上游不支持附件'" aria-label="添加附件" @click="fileInput?.click()">
  419. <AppIcon name="paperclip" :size="16" />
  420. </button>
  421. <div v-if="mode === 'chat'" class="composer-popover-anchor">
  422. <button class="composer-tool" type="button" :class="{ active: reasoningOpen }" :disabled="disabled || !reasoningOptions.length" :title="`推理强度:${reasoningLabel}`" :aria-label="`推理强度:${reasoningLabel}`" :aria-expanded="reasoningOpen" @click="reasoningOpen = !reasoningOpen; settingsOpen = false">
  423. <AppIcon name="brain" :size="16" /><span>{{ reasoningLabel }}</span><AppIcon name="chevron-down" :size="11" />
  424. </button>
  425. <Transition name="composer-menu">
  426. <div v-if="reasoningOpen" class="composer-popover composer-popover--reasoning" role="dialog" aria-label="推理强度">
  427. <div class="composer-popover__heading"><span>推理强度</span><strong>{{ reasoningLabel }}</strong></div>
  428. <input class="reasoning-slider" type="range" :min="0" :max="Math.max(0, reasoningOptions.length - 1)" step="1" :value="reasoningOptionIndex" @input="selectReasoningByIndex" />
  429. <div class="composer-popover__range"><span>{{ reasoningOptions[0]?.label || '默认' }}</span><span>{{ reasoningOptions.at(-1)?.label || '最大' }}</span></div>
  430. </div>
  431. </Transition>
  432. </div>
  433. <div v-if="mode === 'chat'" class="composer-popover-anchor">
  434. <button class="composer-tool" type="button" :class="{ active: settingsOpen }" :disabled="disabled" title="设置" aria-label="设置" :aria-expanded="settingsOpen" @click="settingsOpen = !settingsOpen; reasoningOpen = false">
  435. <AppIcon name="settings" :size="16" /><span>设置</span><AppIcon name="chevron-down" :size="11" />
  436. </button>
  437. <Transition name="composer-menu">
  438. <div v-if="settingsOpen" class="composer-popover composer-popover--settings" role="dialog" aria-label="输入设置">
  439. <button class="composer-setting-row" type="button" role="switch" :aria-checked="toolTraceVisible" @click="emit('settingsClick')">
  440. <span><AppIcon name="brain" :size="15" />显示思考</span>
  441. <AppIcon v-if="toolTraceVisible" name="check" :size="16" />
  442. </button>
  443. <button class="composer-setting-row" type="button" title="首版暂未启用语音输入" @click="showError('语音输入尚未启用')">
  444. <span>语音输入设置</span>
  445. </button>
  446. </div>
  447. </Transition>
  448. </div>
  449. <button v-else class="composer-tool" type="button" :class="{ active: toolTraceVisible }" :disabled="disabled" title="显示思考" @click="emit('toolTraceToggle')">
  450. <AppIcon name="brain" :size="16" /><span>思考</span>
  451. </button>
  452. <button v-if="mode === 'chat'" class="composer-tool composer-tool--model" type="button" :disabled="disabled" :title="modelLabel" @click="emit('modelClick')">
  453. <AppIcon name="model" :size="15" /><span>{{ compactModel }}</span><AppIcon name="chevron-down" :size="11" />
  454. </button>
  455. <button
  456. v-if="mode === 'chat'"
  457. class="composer-tool composer-tool--icon composer-fast-mode"
  458. :class="{ active: fastMode }"
  459. type="button"
  460. :disabled="disabled || fastModeDisabled"
  461. :aria-pressed="fastMode"
  462. :aria-label="`快速模式:${fastMode ? '已开启' : '已关闭'}`"
  463. title="快速模式:响应更快,消耗更多额度"
  464. @click="emit('fastModeToggle', !fastMode)"
  465. >
  466. <AppIcon name="bolt" :size="15" />
  467. </button>
  468. <span v-else-if="mentionOptions.length" class="composer-mention-hint"><b>@</b><span>提及成员</span></span>
  469. </div>
  470. <div class="composer-actions">
  471. <button v-if="mode === 'chat' && streaming && canSubmit" class="queue-toggle" :class="{ active: queueMode }" type="button" :title="queueMode ? '消息将排队发送' : '消息将 Steer 当前会话'" @click="emit('queueToggle')">
  472. {{ queueMode ? '排队' : 'Steer' }}
  473. </button>
  474. <span v-if="sending" class="composer-sending" role="status" aria-live="polite"><i />正在发送</span>
  475. <span class="composer-send-hitbox">
  476. <button
  477. class="composer-send"
  478. :class="{ 'composer-send--stop': showStop }"
  479. type="button"
  480. :disabled="showStop ? false : !canSubmit"
  481. :aria-label="showStop ? '停止生成' : '发送消息'"
  482. @click="showStop ? emit('stop') : submit()"
  483. >
  484. <AppIcon :name="showStop ? 'stop' : 'arrow-up'" :size="showStop ? 17 : 17" />
  485. </button>
  486. </span>
  487. </div>
  488. </div>
  489. <Transition name="composer-menu">
  490. <div v-if="menuKind && menuOptions.length" class="composer-menu" role="listbox">
  491. <div class="composer-menu__label">{{ menuKind === 'mention' ? '提及机器人' : '命令' }}</div>
  492. <button
  493. v-for="(option, index) in menuOptions"
  494. :key="option.id"
  495. type="button"
  496. role="option"
  497. :aria-selected="index === menuIndex"
  498. :disabled="option.disabled"
  499. :class="{ active: index === menuIndex }"
  500. @mousedown.prevent="selectOption(option)"
  501. @mouseenter="menuIndex = index"
  502. >
  503. <span class="composer-menu__avatar">{{ menuKind === 'mention' ? option.label.slice(0, 1).toUpperCase() : '/' }}</span>
  504. <span><strong>{{ menuKind === 'mention' ? `@${option.label}` : `/${option.label}` }}</strong><small v-if="option.detail">{{ option.detail }}</small></span>
  505. <em v-if="option.disabled">不可用</em>
  506. </button>
  507. </div>
  508. </Transition>
  509. <div v-if="dragOver" class="composer-drop-hint"><AppIcon name="paperclip" :size="20" />松开以添加附件</div>
  510. </div>
  511. <Transition name="composer-menu"><p v-if="localError" class="composer-error" role="alert">{{ localError }}</p></Transition>
  512. </div>
  513. </template>
  514. <style scoped>
  515. .composer-area { position: relative; z-index: 12; flex: 0 0 auto; padding: 12px max(24px, calc((100% - 760px) / 2)) max(18px, env(safe-area-inset-bottom)); background: var(--conversation-canvas); }
  516. .composer-activity-slot { display: flex; width: 100%; max-width: 760px; min-height: 20px; margin: 0 auto 3px; align-items: center; }
  517. .composer-activity { display: inline-flex; min-width: 0; align-items: center; gap: 7px; color: var(--text-muted); font-size: 10px; }
  518. .composer-activity strong { overflow: hidden; color: var(--text-secondary); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; }
  519. .composer-typing-dots { display: inline-flex; align-items: center; gap: 2px; }.composer-typing-dots i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: composer-typing 1.05s ease-in-out infinite; }.composer-typing-dots i:nth-child(2) { animation-delay: 140ms; }.composer-typing-dots i:nth-child(3) { animation-delay: 280ms; }
  520. @keyframes composer-typing { 0%, 65%, 100% { opacity: .3; transform: translateY(0); } 32% { opacity: 1; transform: translateY(-2px); } }
  521. .composer-activity-enter-active, .composer-activity-leave-active { transition: opacity 120ms ease, transform 120ms var(--ease-out); }.composer-activity-enter-from, .composer-activity-leave-to { opacity: 0; transform: translateY(3px); }
  522. .composer-shell { position: relative; display: flex; width: 100%; min-height: 72px; max-width: 760px; margin-inline: auto; flex-direction: column; align-items: stretch; gap: 8px; padding: 13px 14px 9px; border: 1px solid var(--input-border-color); border-radius: 18px; background: var(--chat-composer-bg); box-shadow: 0 8px 24px rgba(41,36,39,.06); cursor: text; transition: border-color 140ms ease, box-shadow 150ms ease, background-color 140ms ease; }
  523. .dark .composer-shell { box-shadow: 0 8px 28px rgba(0,0,0,.32); }
  524. .composer-shell:hover:not(:focus-within) { border-color: var(--input-border-hover-color); }
  525. .composer-shell:focus-within { border-color: var(--input-border-focus-color); box-shadow: 0 10px 32px rgba(0,0,0,.11); }
  526. .dark .composer-shell:focus-within { box-shadow: 0 12px 34px rgba(0,0,0,.4); }
  527. .composer-shell--drag { border-color: var(--text-primary) !important; background: color-mix(in srgb, var(--chat-composer-bg) 90%, var(--text-primary)); }
  528. .composer-shell--disabled { opacity: .68; }
  529. .composer-file-input { display: none; }
  530. .composer-resize { position: absolute; z-index: 4; top: -5px; left: 9px; right: 9px; height: 10px; border-radius: 999px; cursor: row-resize; touch-action: none; }
  531. .composer-resize:hover::after { content: ''; position: absolute; top: 4px; left: 38%; right: 38%; height: 2px; border-radius: 2px; background: var(--line-strong); }
  532. .composer-textarea { display: block; width: 100%; min-height: 30px; max-height: 400px; flex: 1 1 auto; padding: 0; border: 0; outline: 0; resize: none; overflow-y: auto; background: none; color: var(--text-primary); font-family: var(--font-ui); font-size: 14px; line-height: 1.5; }
  533. .composer-textarea::placeholder { overflow: hidden; color: var(--input-placeholder-color); opacity: 1; text-overflow: ellipsis; white-space: nowrap; }
  534. .composer-textarea:disabled { cursor: not-allowed; }
  535. .composer-context { position: absolute; z-index: 1; top: 8px; right: 14px; display: flex; align-items: center; gap: 6px; max-width: calc(100% - 28px); color: var(--text-muted); font-size: 10px; pointer-events: none; }
  536. .composer-context span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  537. .composer-context.warning { color: var(--warning); }
  538. .composer-context i { display: block; width: 60px; height: 4px; flex: 0 0 60px; overflow: hidden; border-radius: 3px; background: var(--surface-hover); }
  539. .composer-context b { display: block; height: 100%; border-radius: inherit; background: currentColor; transition: width 220ms ease; }
  540. .composer-context + .composer-textarea { padding-top: 9px; }
  541. .composer-toolbar { display: flex; min-height: 32px; margin-top: auto; align-items: center; justify-content: space-between; gap: 12px; }
  542. .composer-tools, .composer-actions { display: flex; min-width: 0; align-items: center; gap: 5px; }
  543. .composer-tools { flex: 1; }
  544. .composer-actions { flex: 0 0 auto; gap: 7px; }
  545. .composer-popover-anchor { position: relative; display: flex; flex: 0 0 auto; }
  546. .composer-tool { display: inline-flex; min-width: 28px; height: 28px; max-width: 175px; align-items: center; justify-content: center; gap: 4px; padding: 0 6px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 11px; white-space: nowrap; }
  547. .composer-tool:hover, .composer-tool.active { background: var(--surface-hover); color: var(--text-primary); }
  548. .composer-tool:disabled { cursor: not-allowed; opacity: .36; }
  549. .composer-tool span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
  550. .composer-tool--icon { width: 28px; padding: 0; }
  551. .composer-tool--model { min-width: 0; }
  552. .composer-fast-mode.active { background: color-mix(in srgb, #1677ff 12%, transparent); color: #1677ff; }
  553. .composer-fast-mode.active:hover { background: color-mix(in srgb, #1677ff 18%, transparent); color: #1677ff; }
  554. .dark .composer-fast-mode.active { color: #4c9aff; }
  555. .composer-mention-hint { display: inline-flex; align-items: center; gap: 4px; color: var(--text-muted); font-size: 11px; }
  556. .composer-mention-hint b { display: grid; place-items: center; width: 21px; height: 21px; border-radius: 7px; background: var(--surface-soft); color: var(--text-secondary); font-size: 12px; }
  557. .composer-popover { position: absolute; z-index: 30; bottom: calc(100% + 10px); left: 0; width: min(320px, calc(100vw - 48px)); padding: 12px; border: 1px solid var(--line); border-radius: 13px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
  558. .composer-popover--reasoning { --reasoning-accent: #e8a735; }
  559. .composer-popover__heading, .composer-popover__range { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
  560. .composer-popover__heading { margin-bottom: 12px; color: var(--text-secondary); font-size: 12px; }
  561. .composer-popover__heading strong { color: var(--reasoning-accent); font-weight: 650; }
  562. .composer-popover__range { margin-top: 8px; color: var(--text-muted); font-size: 9px; }
  563. .reasoning-slider { display: block; width: 100%; height: 10px; margin: 0; appearance: none; -webkit-appearance: none; border-radius: 99px; background: linear-gradient(90deg, #38bdf8 0%, #22d3ee 20%, #34d399 40%, #facc15 62%, #fb923c 82%, #ef4444 100%); cursor: pointer; }
  564. .reasoning-slider::-webkit-slider-thumb { width: 22px; height: 22px; appearance: none; -webkit-appearance: none; border: 2px solid #fff; border-radius: 50%; background: #f8fafc; box-shadow: 0 2px 8px rgba(24,18,44,.28); }
  565. .reasoning-slider::-moz-range-thumb { width: 18px; height: 18px; border: 2px solid #fff; border-radius: 50%; background: #f8fafc; box-shadow: 0 2px 8px rgba(24,18,44,.28); }
  566. .composer-popover--settings { width: 220px; padding: 6px; }
  567. .composer-setting-row { display: flex; width: 100%; min-height: 38px; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 11px; }
  568. .composer-setting-row:hover { background: var(--surface-hover); color: var(--text-primary); }
  569. .composer-setting-row > span { display: inline-flex; align-items: center; gap: 7px; }
  570. .queue-toggle { height: 26px; padding: 0 7px; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; }
  571. .queue-toggle:hover, .queue-toggle.active { background: var(--surface-hover); color: var(--text-primary); }
  572. .composer-send-hitbox { display: grid; place-items: center; width: 34px; height: 34px; }
  573. .composer-send { display: grid; place-items: center; width: 34px; min-width: 34px; height: 34px; padding: 0; border: 0; border-radius: 50%; background: var(--accent); color: var(--text-on-solid); cursor: pointer; box-shadow: 0 4px 12px rgba(41,36,39,.18); transition: transform 150ms ease, box-shadow 150ms ease; }
  574. .composer-send:not(:disabled):hover { transform: translateY(-1px); box-shadow: 0 7px 16px rgba(41,36,39,.22); }
  575. .composer-send:disabled { cursor: not-allowed; background: #9f9f9f; color: #fff; }
  576. .composer-send--stop { background: var(--accent); }
  577. .composer-sending { display: inline-flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; white-space: nowrap; }.composer-sending i { width: 5px; height: 5px; border-radius: 50%; background: var(--warning); animation: composer-pulse 1s ease-in-out infinite; }
  578. @keyframes composer-pulse { 50% { opacity: .25; transform: scale(.7); } }
  579. .composer-attachments { display: flex; width: 100%; max-width: 760px; margin: 0 auto 9px; flex-wrap: wrap; gap: 7px; }
  580. .composer-attachment { position: relative; display: flex; max-width: 210px; min-height: 48px; align-items: center; gap: 8px; padding: 6px 28px 6px 9px; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-raised); color: var(--text-secondary); }
  581. .composer-attachment.image { width: 64px; height: 64px; padding: 0; }
  582. .composer-attachment img { width: 100%; height: 100%; object-fit: cover; }
  583. .composer-attachment > span { display: flex; min-width: 0; flex-direction: column; }
  584. .composer-attachment strong, .composer-attachment small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  585. .composer-attachment strong { color: var(--text-primary); font-size: 10px; font-weight: 560; }.composer-attachment small { margin-top: 3px; color: var(--text-muted); font-size: 9px; }
  586. .composer-attachment > button { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 50%; background: rgba(0,0,0,.52); color: #fff; cursor: pointer; }
  587. .composer-reference { display: flex; width: calc(100% - 16px); max-width: 744px; min-height: 36px; margin: 0 auto 8px; align-items: center; gap: 9px; padding: 5px 7px 5px 10px; border-left: 2px solid var(--text-muted); border-radius: 7px; background: var(--surface-soft); color: var(--text-muted); }
  588. .composer-reference > span { display: flex; min-width: 0; flex: 1; flex-direction: column; }
  589. .composer-reference small { font-size: 9px; }.composer-reference strong { overflow: hidden; color: var(--text-secondary); font-size: 10px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
  590. .composer-reference button { display: grid; place-items: center; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }
  591. .composer-reference button:hover { background: var(--surface-hover); color: var(--text-primary); }
  592. .composer-menu { position: absolute; z-index: 20; left: 12px; right: 12px; bottom: calc(100% + 8px); max-height: 280px; padding: 5px; overflow-y: auto; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
  593. .composer-menu__label { padding: 5px 8px 7px; color: var(--text-muted); font-size: 9px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
  594. .composer-menu button { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
  595. .composer-menu button.active, .composer-menu button:hover { background: var(--surface-hover); }
  596. .composer-menu button:disabled { cursor: not-allowed; opacity: .48; }
  597. .composer-menu__avatar { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: transparent; color: var(--text-secondary); font-size: 11px; }
  598. .composer-menu button > span:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; }.composer-menu strong { font-size: 11px; }.composer-menu small { overflow: hidden; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.composer-menu em { color: var(--text-muted); font-size: 9px; font-style: normal; }
  599. .composer-drop-hint { position: absolute; z-index: 25; inset: 6px; display: flex; align-items: center; justify-content: center; gap: 8px; border: 1px dashed var(--line-strong); border-radius: 13px; background: color-mix(in srgb, var(--chat-composer-bg) 90%, transparent); color: var(--text-secondary); font-size: 12px; backdrop-filter: blur(5px); pointer-events: none; }
  600. .composer-error { position: absolute; right: max(24px, calc((100% - 760px) / 2)); bottom: calc(100% - 8px); max-width: min(420px, calc(100% - 32px)); margin: 0; padding: 7px 10px; border-radius: 8px; background: var(--danger); color: #fff; box-shadow: var(--shadow-float); font-size: 10px; }
  601. .composer-menu-enter-active, .composer-menu-leave-active { transition: opacity 120ms ease, transform 120ms var(--ease-out); }.composer-menu-enter-from, .composer-menu-leave-to { opacity: 0; transform: translateY(4px); }
  602. @media (max-width: 768px) {
  603. .composer-area { padding: 8px 12px max(12px, env(safe-area-inset-bottom)); }
  604. .composer-shell { min-height: 118px; }
  605. .composer-textarea { font-size: 16px; }
  606. .composer-textarea::placeholder { font-size: 13px; line-height: 1.35; }
  607. .composer-tool span, .composer-tool > :deep(svg:last-child), .composer-mention-hint span { display: none; }
  608. .composer-tool { width: 30px; min-width: 30px; padding: 0; }
  609. .composer-tools { gap: 3px; }
  610. .composer-toolbar { align-items: flex-end; gap: 7px; }
  611. .composer-send-hitbox { width: 44px; height: 44px; margin: -5px; }
  612. .composer-send { width: 34px; min-width: 34px; height: 34px; }
  613. .composer-menu { left: 6px; right: 6px; }
  614. .composer-context { font-size: 8px; }
  615. .composer-context i { display: none; }
  616. .composer-popover { width: min(220px, calc(100vw - 96px)); }
  617. .composer-error { right: 12px; }
  618. }
  619. @media (max-width: 360px) {
  620. .queue-toggle { display: none; }
  621. .composer-toolbar { gap: 3px; }
  622. }
  623. </style>