@huyooo/ai-chat-frontend-vue 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,18 @@
5
5
  @dragleave="imageUpload.handleDragLeave"
6
6
  @drop.prevent="imageUpload.handleDrop"
7
7
  >
8
- <div ref="containerRef" :class="['input-container', { focused: isFocused, 'drag-over': imageUpload.isDragOver.value }]">
8
+ <div
9
+ ref="containerRef"
10
+ :class="[
11
+ 'input-container',
12
+ {
13
+ focused: isFocused,
14
+ 'drag-over': imageUpload.isDragOver.value,
15
+ connecting: voiceInput.status.value === 'connecting',
16
+ recording: voiceIsRecording,
17
+ },
18
+ ]"
19
+ >
9
20
  <!-- 图片预览区 -->
10
21
  <div v-if="imageUpload.hasImages.value" class="images-preview">
11
22
  <div v-for="(img, i) in imageUpload.images.value" :key="i" class="image-preview-item">
@@ -105,36 +116,57 @@
105
116
 
106
117
  <!-- @ 下拉菜单 -->
107
118
  <AtFilePicker
108
- v-if="chatAdapter"
119
+ v-if="props.adapter"
109
120
  :visible="atPickerVisible"
110
- :adapter="chatAdapter"
121
+ :adapter="props.adapter"
111
122
  :anchor-el="atSelectorRef"
112
123
  @close="closeAtPicker"
113
124
  @select="applyAtPath"
114
125
  />
115
126
  </div>
116
127
 
117
- <!-- 发送/停止按钮 -->
128
+ <!-- 方案 A:录音按钮 + 发送按钮独立(停止录音入口永不消失) -->
118
129
  <button
119
- v-if="inputText.trim() || imageUpload.hasImages.value || isLoading"
120
- :class="['send-btn', { loading: isLoading }]"
121
- :title="isLoading ? '停止' : isMessageVariant ? '重新发送' : '发送'"
130
+ class="voice-btn"
131
+ :class="{ connecting: voiceInput.status.value === 'connecting', recording: voiceIsRecording }"
132
+ :title="voiceInput.status.value === 'connecting' ? '正在连接,点击取消' : voiceIsRecording ? '点击停止' : '点击录音'"
133
+ @click.prevent="toggleVoiceInput"
134
+ :disabled="isLoading"
135
+ >
136
+ <Icon v-if="voiceInput.status.value === 'connecting'" icon="lucide:loader-2" width="18" class="spin" />
137
+ <Icon v-else-if="voiceIsRecording" icon="streamline-flex:button-pause-circle-remix" width="18" />
138
+ <Icon v-else icon="lucide:mic" width="18" />
139
+ </button>
140
+
141
+ <button
142
+ class="send-btn"
143
+ :class="{ loading: isLoading }"
144
+ :disabled="(!inputText.trim() && !imageUpload.hasImages.value) || voiceIsRecording || voiceInput.status.value === 'connecting'"
145
+ :title="
146
+ isLoading
147
+ ? '停止'
148
+ : voiceInput.status.value === 'connecting'
149
+ ? '语音连接中,先取消/停止'
150
+ : voiceIsRecording
151
+ ? '录音中,先停止'
152
+ : isMessageVariant
153
+ ? '重新发送'
154
+ : '发送'
155
+ "
122
156
  @click="handleSendOrCancel"
123
157
  >
124
158
  <Icon v-if="isLoading" icon="streamline-flex:button-pause-circle-remix" width="18" />
125
159
  <Icon v-else icon="streamline-flex:mail-send-email-message-circle-remix" width="18" />
126
160
  </button>
127
- <button v-else class="icon-btn" title="语音输入">
128
- <Icon icon="lucide:mic" width="18" />
129
- </button>
130
161
  </div>
131
162
  </div>
163
+
132
164
  </div>
133
165
  </div>
134
166
  </template>
135
167
 
136
168
  <script setup lang="ts">
137
- import { computed, inject, ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
169
+ import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
138
170
  import { Icon } from '@iconify/vue';
139
171
  import type { ChatMode, ModelOption } from '../../types';
140
172
  import AtFilePicker from './AtFilePicker.vue';
@@ -142,6 +174,7 @@ import ImagePreviewModal from './ImagePreviewModal.vue';
142
174
  import DropdownSelector, { type GroupedOptions } from './DropdownSelector.vue';
143
175
  import type { ChatAdapter, ImageData } from '../../adapter';
144
176
  import { useImageUpload } from '../../composables/useImageUpload';
177
+ import { useVoiceToTextInput } from '../../composables/useVoiceToTextInput';
145
178
 
146
179
  const props = withDefaults(
147
180
  defineProps<{
@@ -150,6 +183,8 @@ const props = withDefaults(
150
183
  /** 受控值(用于历史消息编辑) */
151
184
  value?: string;
152
185
  isLoading?: boolean;
186
+ /** 由宿主显式传入(@ 面板需要) */
187
+ adapter?: ChatAdapter;
153
188
  mode?: ChatMode;
154
189
  model?: string;
155
190
  /** 模型列表 */
@@ -161,6 +196,7 @@ const props = withDefaults(
161
196
  variant: 'input',
162
197
  value: '',
163
198
  isLoading: false,
199
+ adapter: undefined,
164
200
  mode: 'agent',
165
201
  model: '',
166
202
  models: () => [],
@@ -188,13 +224,25 @@ const containerRef = ref<HTMLDivElement | null>(null);
188
224
  const isFocused = ref(false);
189
225
  const lastInputText = ref(props.value);
190
226
 
191
- // 注入 adapter(由 ChatPanel 提供)
192
- const chatAdapter = inject<ChatAdapter | null>('chatAdapter', null);
227
+ // 显式 adapter(由 ChatPanel / MessageBubble 通过 props 传递)
228
+ const chatAdapter = computed(() => props.adapter);
193
229
 
194
230
  // ============ 图片上传功能(使用 composable) ============
195
231
  const imageUpload = useImageUpload();
196
232
  const imageInputRef = ref<HTMLInputElement | null>(null);
197
233
 
234
+ // ============ 语音输入功能(高内聚控制层) ============
235
+ const voiceCtl = useVoiceToTextInput({
236
+ adapter: props.adapter,
237
+ inputText,
238
+ hasImages: computed(() => imageUpload.hasImages.value),
239
+ isLoading: computed(() => props.isLoading),
240
+ focusInput: () => inputRef.value?.focus(),
241
+ adjustTextareaHeight,
242
+ });
243
+ const voiceInput = voiceCtl.voiceInput;
244
+ const voiceIsRecording = computed(() => voiceInput.status.value === 'recording');
245
+
198
246
  // 预览索引(使用 computed 双向绑定到 composable)
199
247
  const previewIndex = computed({
200
248
  get: () => imageUpload.previewIndex.value,
@@ -206,13 +254,20 @@ function triggerImageUpload() {
206
254
  imageInputRef.value?.click();
207
255
  }
208
256
 
257
+ // ============ 语音输入操作 ============
258
+
259
+ /** 切换语音输入(点击切换模式) */
260
+ async function toggleVoiceInput() {
261
+ await voiceCtl.toggleVoice();
262
+ }
263
+
209
264
  // @ 文件选择面板
210
265
  const atPickerVisible = ref(false);
211
266
  const replaceRange = ref<{ start: number; end: number } | null>(null);
212
267
  const atSelectorRef = ref<HTMLDivElement | null>(null);
213
268
 
214
269
  function toggleAtPicker() {
215
- if (!chatAdapter) return;
270
+ if (!chatAdapter.value) return;
216
271
  if (!atPickerVisible.value) {
217
272
  replaceRange.value = null;
218
273
  }
@@ -334,7 +389,7 @@ function adjustTextareaHeight() {
334
389
  // 检测是否新增输入了 '@',用事件驱动打开面板
335
390
  const cur = inputText.value || '';
336
391
  const prev = lastInputText.value || '';
337
- if (chatAdapter && cur.length === prev.length + 1 && inputRef.value) {
392
+ if (chatAdapter.value && cur.length === prev.length + 1 && inputRef.value) {
338
393
  const cursor = inputRef.value.selectionStart ?? cur.length;
339
394
  const inserted = cur.slice(cursor - 1, cursor);
340
395
  if (inserted === '@') {
@@ -377,6 +432,7 @@ function handleSendOrCancel() {
377
432
  }
378
433
 
379
434
  function handleKeydown(event: KeyboardEvent) {
435
+ if (voiceCtl.handleKeydownForVoice(event)) return;
380
436
  if (event.key === 'Enter' && !event.shiftKey) {
381
437
  event.preventDefault();
382
438
  handleSendOrCancel();
@@ -460,6 +516,7 @@ defineExpose({
460
516
  }
461
517
 
462
518
  .input-container {
519
+ position: relative;
463
520
  display: flex;
464
521
  flex-direction: column;
465
522
  background: var(--chat-input-bg, #2d2d2d);
@@ -478,6 +535,8 @@ defineExpose({
478
535
  background: rgba(37, 99, 235, 0.1);
479
536
  }
480
537
 
538
+ /* 录音态不改变 textarea 样式(仅改变图标颜色) */
539
+
481
540
  /* 图片预览区 */
482
541
  .images-preview {
483
542
  display: flex;
@@ -609,6 +668,8 @@ defineExpose({
609
668
 
610
669
  .input-right .icon-btn:hover {
611
670
  color: var(--chat-text, #ccc);
671
+ background: rgba(0, 0, 0, 0.06);
672
+ background: color-mix(in srgb, var(--chat-text, #ccc) 10%, transparent);
612
673
  }
613
674
 
614
675
  .toggle-btn {
@@ -627,17 +688,22 @@ defineExpose({
627
688
 
628
689
  .toggle-btn:hover {
629
690
  color: var(--chat-text, #ccc);
691
+ background: rgba(0, 0, 0, 0.06);
692
+ background: color-mix(in srgb, var(--chat-text, #ccc) 10%, transparent);
630
693
  }
631
694
 
632
695
  .toggle-btn.active {
633
696
  /* 激活态使用主色调(仅配色,不改布局) */
634
697
  color: var(--chat-primary);
635
- background: var(--chat-muted);
698
+ /* 避免用 --chat-muted 做背景(与 input 背景太接近,暗色几乎无对比) */
699
+ background: rgba(84, 169, 255, 0.14);
700
+ background: color-mix(in srgb, var(--chat-primary) 14%, transparent);
636
701
  }
637
702
 
638
703
  .toggle-btn.active:hover {
639
704
  color: var(--chat-primary-hover);
640
- background: var(--chat-muted-hover);
705
+ background: rgba(84, 169, 255, 0.2);
706
+ background: color-mix(in srgb, var(--chat-primary) 20%, transparent);
641
707
  }
642
708
 
643
709
  .send-btn {
@@ -656,5 +722,87 @@ defineExpose({
656
722
 
657
723
  .send-btn:hover {
658
724
  color: var(--chat-text, #ccc);
725
+ background: rgba(0, 0, 0, 0.06);
726
+ background: color-mix(in srgb, var(--chat-text, #ccc) 10%, transparent);
727
+ }
728
+
729
+ .send-btn:disabled {
730
+ opacity: 0.5;
731
+ cursor: not-allowed;
732
+ }
733
+
734
+ .send-btn:disabled:hover {
735
+ color: var(--chat-text-muted, #666);
736
+ }
737
+
738
+ /* 语音输入按钮 */
739
+ .voice-btn {
740
+ display: flex;
741
+ align-items: center;
742
+ justify-content: center;
743
+ width: 28px;
744
+ height: 28px;
745
+ background: transparent;
746
+ border: none;
747
+ border-radius: 6px;
748
+ color: var(--chat-text-muted, #666);
749
+ cursor: pointer;
750
+ transition: all 0.15s;
751
+ }
752
+
753
+ .voice-btn:disabled {
754
+ opacity: 0.5;
755
+ cursor: not-allowed;
756
+ }
757
+
758
+ .voice-btn:disabled:hover {
759
+ background: transparent;
760
+ color: var(--chat-text-muted, #666);
761
+ }
762
+
763
+ .voice-btn:hover {
764
+ color: var(--chat-text, #ccc);
765
+ background: rgba(0, 0, 0, 0.06);
766
+ background: color-mix(in srgb, var(--chat-text, #ccc) 10%, transparent);
659
767
  }
768
+
769
+ .voice-btn.recording {
770
+ color: #ef4444;
771
+ background: transparent;
772
+ animation: none;
773
+ }
774
+
775
+ .voice-btn.recording:hover {
776
+ color: #ef4444;
777
+ background: rgba(0, 0, 0, 0.06);
778
+ background: color-mix(in srgb, var(--chat-text, #ccc) 10%, transparent);
779
+ }
780
+
781
+ .voice-btn.connecting {
782
+ color: #f59e0b;
783
+ background: transparent;
784
+ }
785
+
786
+ .voice-btn.connecting:hover {
787
+ color: #f59e0b;
788
+ background: rgba(0, 0, 0, 0.06);
789
+ background: color-mix(in srgb, var(--chat-text, #ccc) 10%, transparent);
790
+ }
791
+
792
+ .voice-btn .spin {
793
+ animation: spin 1s linear infinite;
794
+ }
795
+
796
+ .voice-btn .stop-icon {
797
+ animation: none;
798
+ }
799
+
800
+ @keyframes spin {
801
+ from { transform: rotate(0deg); }
802
+ to { transform: rotate(360deg); }
803
+ }
804
+
805
+ /* 录音态动效:仅对图标做“呼吸”效果(无背景变化) */
806
+ /* 录音态图标不加动效 */
807
+
660
808
  </style>
@@ -182,8 +182,11 @@ onUnmounted(() => {
182
182
  display: flex;
183
183
  align-items: center;
184
184
  gap: 4px;
185
- padding: 4px 8px;
186
- background: var(--chat-muted, #3c3c3c);
185
+ height: 28px; /* 与 ChatInput 右侧按钮一致 */
186
+ padding: 0 8px;
187
+ /* 避免用 --chat-muted 做背景(暗色下容易与 input 背景同色) */
188
+ background: rgba(0, 0, 0, 0.04);
189
+ background: color-mix(in srgb, var(--chat-text, #ccc) 6%, transparent);
187
190
  border: none;
188
191
  border-radius: 6px;
189
192
  font-size: 14px;
@@ -193,7 +196,8 @@ onUnmounted(() => {
193
196
  }
194
197
 
195
198
  .dropdown-selector:hover:not(.disabled) {
196
- background: var(--chat-muted-hover, #444);
199
+ background: rgba(0, 0, 0, 0.06);
200
+ background: color-mix(in srgb, var(--chat-text, #ccc) 10%, transparent);
197
201
  color: var(--chat-text, #ccc);
198
202
  }
199
203
 
@@ -270,12 +274,14 @@ onUnmounted(() => {
270
274
  }
271
275
 
272
276
  .dropdown-item:hover {
273
- background: rgba(255, 255, 255, 0.08);
277
+ background: rgba(0, 0, 0, 0.06);
278
+ background: color-mix(in srgb, var(--chat-text, #ccc) 10%, transparent);
274
279
  color: var(--chat-text, #ccc);
275
280
  }
276
281
 
277
282
  .dropdown-item.active {
278
- background: rgba(255, 255, 255, 0.1);
283
+ background: rgba(0, 0, 0, 0.08);
284
+ background: color-mix(in srgb, var(--chat-text, #ccc) 14%, transparent);
279
285
  color: var(--chat-text, #fff);
280
286
  }
281
287
 
@@ -12,6 +12,7 @@
12
12
  :web-search-enabled="inputState.webSearch.value"
13
13
  :thinking-enabled="inputState.thinking.value"
14
14
  :is-loading="inputState.isLoading.value"
15
+ :adapter="props.adapter"
15
16
  @send="(text) => $emit('send', text)"
16
17
  @update:mode="inputState.setMode"
17
18
  @update:model="inputState.setModel"