@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.
- package/dist/components/ChatPanel.vue.d.ts +4 -0
- package/dist/components/ChatPanel.vue.d.ts.map +1 -1
- package/dist/components/input/ChatInput.vue.d.ts +4 -1
- package/dist/components/input/ChatInput.vue.d.ts.map +1 -1
- package/dist/components/input/DropdownSelector.vue.d.ts.map +1 -1
- package/dist/components/message/MessageBubble.vue.d.ts.map +1 -1
- package/dist/composables/useVoiceInput.d.ts +39 -0
- package/dist/composables/useVoiceInput.d.ts.map +1 -0
- package/dist/composables/useVoiceToTextInput.d.ts +18 -0
- package/dist/composables/useVoiceToTextInput.d.ts.map +1 -0
- package/dist/index.js +4200 -3901
- package/dist/style.css +1 -1
- package/package.json +3 -3
- package/src/components/ChatPanel.vue +1 -0
- package/src/components/input/AtFilePicker.vue +19 -19
- package/src/components/input/ChatInput.vue +165 -17
- package/src/components/input/DropdownSelector.vue +11 -5
- package/src/components/message/MessageBubble.vue +1 -0
- package/src/composables/useVoiceInput.ts +531 -0
- package/src/composables/useVoiceToTextInput.ts +94 -0
|
@@ -5,7 +5,18 @@
|
|
|
5
5
|
@dragleave="imageUpload.handleDragLeave"
|
|
6
6
|
@drop.prevent="imageUpload.handleDrop"
|
|
7
7
|
>
|
|
8
|
-
<div
|
|
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="
|
|
119
|
+
v-if="props.adapter"
|
|
109
120
|
:visible="atPickerVisible"
|
|
110
|
-
:adapter="
|
|
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
|
-
|
|
120
|
-
:class="
|
|
121
|
-
:title="
|
|
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,
|
|
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
|
-
//
|
|
192
|
-
const chatAdapter =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
186
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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"
|