ai-chat-ui/src/components/input/ChatInput.vue

788 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
class="chat-input-container"
:class="{ 'is-focused': isFocused, 'is-expanded': isExpanded }"
>
<!-- 输入区域 -->
<div class="input-area">
<!-- 左侧功能按钮 -->
<div class="input-actions left">
<!-- 附件按钮 -->
<button
class="action-btn"
:class="{ disabled: !supports_files }"
:disabled="!supports_files"
:title="supports_files ? '添加附件' : '当前模型不支持文件附件'"
@click="supports_files && triggerFileInput()"
>
<Paperclip :size="20" />
</button>
<!-- 图片按钮 -->
<button
class="action-btn"
:class="{ disabled: !supports_vision }"
:disabled="!supports_vision"
:title="supports_vision ? '添加图片' : '当前模型不支持图片识别'"
@click="supports_vision && triggerImageInput()"
>
<Image :size="20" />
</button>
<!-- 隐藏的文件输入框 -->
<input
ref="fileInputRef"
type="file"
multiple
hidden
@change="handleFileSelect"
/>
<input
ref="imageInputRef"
type="file"
accept="image/*"
multiple
hidden
@change="handleImageSelect"
/>
</div>
<!-- 文本输入框 -->
<div class="textarea-wrapper">
<textarea
ref="textareaRef"
v-model="inputText"
:placeholder="placeholder"
:rows="1"
@beforeinput="handleBeforeInput"
@input="autoResize"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown="handleKeydown"
@paste="handlePaste"
/>
</div>
<!-- 右侧功能按钮 -->
<div class="input-actions right">
<!-- 发送/停止按钮 -->
<button
v-if="isStreaming"
class="action-btn stop"
title="停止生成"
@click="$emit('stop')"
>
<StopCircle :size="20" />
</button>
<button
v-else
class="action-btn send"
:class="{ active: canSend, loading: isUploading }"
:disabled="!canSend"
:title="isUploading ? '上传中...' : '发送消息 (Ctrl+Enter)'"
@click="handleSend"
>
<Loader2 v-if="isUploading" :size="20" class="animate-spin" />
<Send v-else :size="20" />
</button>
</div>
</div>
<!-- 底部工具栏 -->
<div class="input-toolbar">
<div class="toolbar-left">
<!-- 展开/收起 -->
<button class="toolbar-btn" title="展开输入框" @click="toggleExpand">
<Maximize2 v-if="!isExpanded" :size="16" />
<Minimize2 v-else :size="16" />
</button>
<!-- 深度思考开关 -->
<button
class="toolbar-btn"
:class="{ active: isDeepThinking, disabled: isForceDeepThinkingModel || !supports_thinking }"
:disabled="isForceDeepThinkingModel || !supports_thinking"
:title="isForceDeepThinkingModel ? '当前模型强制开启深度思考' : (supports_thinking ? '深度思考' : '当前模型不支持深度思考')"
@click="!isForceDeepThinkingModel && supports_thinking && toggleDeepThink()"
>
<Brain :size="16" />
<span>深度思考</span>
</button>
<!-- 深度搜索开关 -->
<button
class="toolbar-btn"
:class="{ active: isDeepSearch, disabled: !supports_web_search }"
:disabled="!supports_web_search"
:title="supports_web_search ? '深度搜索' : '当前模型不支持联网搜索'"
@click="supports_web_search && toggleDeepSearch()"
>
<Sparkles :size="16" />
<span>深度搜索</span>
</button>
<!-- 联网搜索开关 -->
<button
class="toolbar-btn"
:class="{ active: isWebSearch, disabled: !supports_web_search }"
:disabled="!supports_web_search"
:title="supports_web_search ? '联网搜索' : '当前模型不支持联网搜索'"
@click="supports_web_search && toggleWebSearch()"
>
<Globe :size="16" />
<span>联网搜索</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from "vue";
import {
Paperclip,
Image,
Send,
StopCircle,
Sparkles,
Globe,
Maximize2,
Minimize2,
Brain,
Loader2,
} from "@/components/icons";
import { generateId } from "@/utils/helpers";
import type { Attachment } from "@/types/chat";
import { chatApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
interface AttachmentWithProgress extends Attachment {
uploading?: boolean;
progress?: number;
}
const props = withDefaults(
defineProps<{
placeholder?: string;
isStreaming?: boolean;
sendOnEnter?: boolean;
maxChars?: number;
disabled?: boolean;
// 模型能力
supports_thinking?: boolean;
supports_web_search?: boolean;
supports_vision?: boolean;
supports_files?: boolean;
}>(),
{
placeholder: "输入你的问题...",
isStreaming: false,
sendOnEnter: false,
maxChars: 10000,
disabled: false,
// 默认全部支持
supports_thinking: true,
supports_web_search: true,
supports_vision: true,
supports_files: true,
},
);
// 强制深度思考的模型列表(这些模型默认开启深度思考且不可关闭)
const FORCE_DEEP_THINKING_MODELS = ["deepseek-reasoner", "glm-z1-flash"];
// 判断当前模型是否强制开启深度思考
const isForceDeepThinkingModel = computed(() => {
return FORCE_DEEP_THINKING_MODELS.includes(modelName.value.toLowerCase());
});
const emit = defineEmits<{
send: [
text: string,
attachments: Attachment[],
options: { deepSearch: boolean; webSearch: boolean; deepThinking: boolean },
];
stop: [];
}>();
// 响应式状态
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
const modelName = computed(() => settingsStore.settings.defaultModel);
const inputText = ref("");
const attachments = ref<AttachmentWithProgress[]>([]);
const isFocused = ref(false);
const isExpanded = ref(false);
const isDeepSearch = ref(
JSON.parse(localStorage.getItem("isDeepSearch") || "false"),
);
const isDeepThinking = ref(
JSON.parse(localStorage.getItem("isDeepThinking") || "false"),
);
const isWebSearch = ref(
JSON.parse(localStorage.getItem("isWebSearch") || "false"),
);
// DOM引用
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
// toast 节流
let lastToastTime = 0;
const toastThrottleMs = 2000;
function showThrottledToast(message: string, type: "error" = "error") {
const now = Date.now();
if (now - lastToastTime >= toastThrottleMs) {
lastToastTime = now;
window.$toast?.(message, type);
}
}
// 计算属性
const charCount = computed(() => inputText.value.length);
const isUploading = computed(() => attachments.value.some((a) => a.uploading));
const canSend = computed(() => {
return (
(inputText.value.trim().length > 0 || attachments.value.length > 0) &&
!props.disabled &&
charCount.value <= props.maxChars &&
!isUploading.value
);
});
// 自动调整高度
function autoResize() {
const textarea = textareaRef.value;
if (!textarea) return;
textarea.style.height = "auto";
const maxHeight = isExpanded.value ? 400 : 160;
// 增加1px是为了避免滚动条出现
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight) + 1}px`;
}
// 处理输入前事件,限制字数
function handleBeforeInput(event: InputEvent) {
// 如果不是插入文本的操作(如删除、退格等),允许
if (!event.data) return;
// 检查输入后是否会超过限制
const currentLength = inputText.value.length;
const insertLength = event.data?.length || 0;
const selectionStart =
(event.target as HTMLTextAreaElement).selectionStart || 0;
const selectionEnd = (event.target as HTMLTextAreaElement).selectionEnd || 0;
const selectedLength = selectionEnd - selectionStart;
// 计算输入后的长度
const newLength = currentLength - selectedLength + insertLength;
if (newLength > props.maxChars) {
event.preventDefault();
showThrottledToast(`已超${props.maxChars}字上限,请删除部分内容`);
}
}
// 处理键盘事件
function handleKeydown(event: KeyboardEvent) {
// Ctrl+Enter 或 Cmd+Enter 发送
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
handleSend();
return;
}
// Enter 发送(如果开启了这个选项且没有按 Shift
if (props.sendOnEnter && event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
return;
}
}
// 发送消息
function handleSend() {
if (!canSend.value) return;
emit("send", inputText.value.trim(), [...attachments.value], {
deepSearch: isDeepSearch.value,
webSearch: isWebSearch.value,
deepThinking: isDeepThinking.value,
});
// 重置状态
inputText.value = "";
attachments.value = [];
// isDeepSearch.value = false;
// isWebSearch.value = false;
// isDeepThinking.value = false;
nextTick(() => {
autoResize();
});
}
// 处理粘贴事件
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
// 检查粘贴文本是否会超过字数限制
const text = event.clipboardData?.getData("text");
if (text) {
const textarea = event.target as HTMLTextAreaElement;
const selectionStart = textarea.selectionStart || 0;
const selectionEnd = textarea.selectionEnd || 0;
const selectedLength = selectionEnd - selectionStart;
const newLength = inputText.value.length - selectedLength + text.length;
if (newLength > props.maxChars) {
event.preventDefault();
showThrottledToast(`已超${props.maxChars}字上限,请删除部分内容`);
return;
}
}
for (const item of items) {
if (item.type.startsWith("image/")) {
event.preventDefault();
const file = item.getAsFile();
if (file) {
await addFileAsAttachment(file, "image");
}
}
}
}
// 触发文件选择
function triggerFileInput() {
fileInputRef.value?.click();
}
function triggerImageInput() {
imageInputRef.value?.click();
}
// 处理文件选择
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files) return;
for (const file of files) {
await addFileAsAttachment(file, "file");
}
input.value = "";
}
async function handleImageSelect(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files) return;
for (const file of files) {
await addFileAsAttachment(file, "image");
}
input.value = "";
}
// 添加文件作为附件
async function addFileAsAttachment(
file: File,
type: "image" | "file" | "video",
) {
// 检查认证状态
if (!authStore.isAuthenticated) {
window.$toast?.("请先登录", "error");
return;
}
const id = generateId();
// 创建本地预览URL
const url = URL.createObjectURL(file);
const attachment: AttachmentWithProgress = {
id,
name: file.name,
type,
url,
size: file.size,
mimeType: file.type,
uploading: true,
progress: 0,
};
attachments.value.push(attachment);
// 实际上传文件到服务器
await uploadFileToServer(id, file);
}
// 上传文件到服务器
async function uploadFileToServer(id: string, file: File) {
try {
const uploadResult = await chatApi.uploadFile(file);
const attachment = attachments.value.find((a) => a.id === id);
if (attachment) {
// 替换本地预览URL为服务器返回的真实URL
attachment.url = uploadResult.url;
attachment.uploading = false;
attachment.progress = 100;
}
// 显示上传成功提示
window.$toast && window.$toast("文件上传成功", "success");
} catch (error) {
console.error("文件上传失败:", error);
const attachment = attachments.value.find((a) => a.id === id);
if (attachment) {
attachment.uploading = false;
// 设置错误状态或者移除附件
removeAttachment(id);
}
// 显示错误提示
window.$toast && window.$toast("文件上传失败,请重试", "error");
}
}
// 移除附件
async function removeAttachment(id: string) {
const index = attachments.value.findIndex((a) => a.id === id);
if (index === -1) return;
const attachment = attachments.value[index];
// 如果已上传到 OSS不是本地 blob URL则从 OSS 删除
if (attachment.url && !attachment.url.startsWith("blob:")) {
try {
await chatApi.deleteAttachment(attachment.url);
} catch (error) {
console.error("删除 OSS 文件失败:", error);
// 即使删除失败也继续移除本地引用
}
}
// 释放 blob URL如果是本地的
if (attachment.url.startsWith("blob:")) {
URL.revokeObjectURL(attachment.url);
}
attachments.value.splice(index, 1);
}
// 切换功能(深度搜索与联网搜索互斥)
function toggleDeepSearch() {
isDeepSearch.value = !isDeepSearch.value;
if (isDeepSearch.value) {
isWebSearch.value = false;
localStorage.setItem("isWebSearch", "false");
}
localStorage.setItem("isDeepSearch", String(isDeepSearch.value));
}
function toggleDeepThink() {
isDeepThinking.value = !isDeepThinking.value;
localStorage.setItem("isDeepThinking", String(isDeepThinking.value));
}
function toggleWebSearch() {
isWebSearch.value = !isWebSearch.value;
if (isWebSearch.value) {
isDeepSearch.value = false;
localStorage.setItem("isDeepSearch", "false");
}
localStorage.setItem("isWebSearch", String(isWebSearch.value));
}
function toggleExpand() {
isExpanded.value = !isExpanded.value;
nextTick(() => {
autoResize();
});
}
// 暴露方法给父组件
function focus() {
textareaRef.value?.focus();
}
function clear() {
inputText.value = "";
attachments.value = [];
nextTick(() => {
autoResize();
});
}
defineExpose({
focus,
clear,
attachments,
removeAttachment,
});
// 监听文本变化,自动调整高度
watch(inputText, () => {
nextTick(() => {
autoResize();
});
});
// 监听模型变化,重置选项功能
watch(
() => settingsStore.settings.defaultModel,
(newModel) => {
// 如果是强制深度思考的模型,自动开启深度思考
if (FORCE_DEEP_THINKING_MODELS.includes(newModel.toLowerCase())) {
isDeepThinking.value = true;
localStorage.setItem("isDeepThinking", "true");
} else {
isDeepThinking.value = false;
localStorage.setItem("isDeepThinking", "false");
}
// 重置搜索相关选项
isDeepSearch.value = false;
isWebSearch.value = false;
localStorage.setItem("isDeepSearch", "false");
localStorage.setItem("isWebSearch", "false");
},
);
onMounted(() => {
autoResize();
});
</script>
<style lang="scss" scoped>
.chat-input-container {
background: #f3f4f5;
// border: 2px solid #e2e8f0;
border-radius: 20px;
overflow: hidden;
transition: all 0.2s ease;
.dark & {
background: #1e1e2e;
border-color: #374151;
}
// &.is-focused {
// border-color: #3b82f6;
// box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
// }
&.is-expanded {
.textarea-wrapper textarea {
min-height: 200px;
}
}
}
.input-area {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
}
.input-actions {
display: flex;
align-items: center;
gap: 4px;
padding-bottom: 4px;
&.left {
flex-shrink: 0;
}
&.right {
flex-shrink: 0;
}
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border: none;
border-radius: 12px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #9f9fa05c;
color: #374151;
.dark & {
background: #374151;
color: #e5e7eb;
}
}
&.disabled,
&:disabled {
opacity: 0.4;
cursor: not-allowed;
&:hover {
background: transparent;
color: #6b7280;
}
}
&.send {
background: #e5e7eb;
color: #9ca3af;
.dark & {
background: #374151;
}
&.active {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
}
&.loading {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
cursor: wait;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&.stop {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
animation: pulse 2s infinite;
&:hover {
transform: scale(1.05);
}
}
}
.textarea-wrapper {
flex: 1;
min-width: 0;
textarea {
width: 100%;
min-height: 25px;
max-height: 160px;
padding: 8px 0;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 15px;
line-height: 1.5;
color: #1f2937;
resize: none;
.dark & {
color: #f3f4f6;
}
&::placeholder {
color: #9ca3af;
}
}
}
.input-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-top: 1px solid #f3f4f6;
// background: #fafbfc;
.dark & {
border-top-color: #2d2d3d;
background: #181825;
}
}
.toolbar-left {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #6b7280;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #9f9fa05c;
color: #374151;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
}
}
&.active {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.3);
color: #3b82f6;
svg {
color: #3b82f6;
}
}
&.disabled,
&:disabled {
opacity: 0.4;
cursor: not-allowed;
&:hover {
background: transparent;
color: #6b7280;
}
}
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
}
}
</style>