751 lines
17 KiB
Vue
751 lines
17 KiB
Vue
<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"
|
||
@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"
|
||
:class="{ active: isDeepThinking, disabled: !supports_thinking }"
|
||
:disabled="!supports_thinking"
|
||
:title="supports_thinking ? '深度思考' : '当前模型不支持深度思考'"
|
||
@click="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>
|
||
|
||
<!-- 展开/收起 -->
|
||
<button class="toolbar-btn" title="展开输入框" @click="toggleExpand">
|
||
<Maximize2 v-if="!isExpanded" :size="16" />
|
||
<Minimize2 v-else :size="16" />
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-right">
|
||
<span
|
||
class="char-count"
|
||
:class="{ warning: charCount > maxChars * 0.9 }"
|
||
>
|
||
{{ charCount }} / {{ maxChars }}
|
||
</span>
|
||
<span class="send-hint">
|
||
{{ sendOnEnter ? "Enter 发送, Shift+Enter 换行" : "Ctrl+Enter 发送" }}
|
||
</span>
|
||
</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 AttachmentPreview from "./AttachmentPreview.vue";
|
||
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: 4000,
|
||
disabled: false,
|
||
// 默认全部支持
|
||
supports_thinking: true,
|
||
supports_web_search: true,
|
||
supports_vision: true,
|
||
supports_files: true,
|
||
},
|
||
);
|
||
|
||
const emit = defineEmits<{
|
||
send: [
|
||
text: string,
|
||
attachments: Attachment[],
|
||
options: { deepSearch: boolean; webSearch: boolean; deepThinking: boolean },
|
||
];
|
||
stop: [];
|
||
}>();
|
||
|
||
// 响应式状态
|
||
const authStore = useAuthStore();
|
||
const settingsStore = useSettingsStore();
|
||
|
||
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);
|
||
|
||
// 计算属性
|
||
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;
|
||
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
|
||
}
|
||
|
||
// 处理键盘事件
|
||
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;
|
||
|
||
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,
|
||
() => {
|
||
isDeepSearch.value = false;
|
||
isDeepThinking.value = false;
|
||
isWebSearch.value = 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: 24px;
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
.toolbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.char-count {
|
||
font-size: 12px;
|
||
color: #9ca3af;
|
||
|
||
&.warning {
|
||
color: #f59e0b;
|
||
}
|
||
}
|
||
|
||
.send-hint {
|
||
font-size: 12px;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
@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>
|