mateclaw/mateclaw-ui/src/components/chat/ChatInput.vue

786 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
ref="containerRef"
class="chat-input-wrapper"
:class="{
'is-focused': isFocused,
'is-disabled': disabled,
'is-loading': loading,
}"
>
<!-- 附件列表 -->
<div v-if="attachments.length" class="attachment-list">
<div
v-for="attachment in attachments"
:key="attachment.storedName || attachment.path"
class="attachment-chip"
:class="{
'attachment-chip--dir': attachment.contentType === 'inode/directory',
'attachment-chip--image': attachment.contentType?.startsWith('image/'),
}"
>
<!-- 图片缩略图预览(优先用本地 previewUrl避免 JWT 认证问题) -->
<img
v-if="attachment.contentType?.startsWith('image/') && (attachment.previewUrl || attachment.url)"
:src="attachment.previewUrl || attachment.url"
:alt="attachment.name"
class="attachment-chip__thumbnail"
loading="lazy"
/>
<component
:is="attachment.url ? 'a' : 'span'"
:href="attachment.url || undefined"
target="_blank"
rel="noreferrer"
class="attachment-chip__label"
>
<span>{{ attachment.contentType === 'inode/directory' ? '📁 ' : '' }}{{ attachment.name }}</span>
<span v-if="attachment.size">{{ formatFileSize(attachment.size) }}</span>
<span v-else-if="attachment.contentType === 'inode/directory'" class="attachment-chip__path">{{ attachment.path }}</span>
</component>
<button
type="button"
class="attachment-chip__remove"
@click="removeAttachment(attachment.storedName || attachment.path)"
>
×
</button>
</div>
</div>
<!-- 审批栏:有待审批时替换输入区域 -->
<div v-if="pendingApproval?.status === 'pending_approval'" class="approval-bar">
<div class="approval-bar__info">
<span class="approval-bar__icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</span>
<span class="approval-bar__label">{{ t('chat.approvalAllow') }}</span>
<span class="approval-bar__tool">{{ pendingApproval.toolName }}</span>
<span class="approval-bar__label">{{ t('chat.approvalExecute') }}</span>
</div>
<div class="approval-bar__actions">
<button
type="button"
class="approval-bar__btn approval-bar__btn--deny"
@click="emit('deny', pendingApproval.pendingId)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
{{ t('chat.deny') }}
</button>
<button
type="button"
class="approval-bar__btn approval-bar__btn--approve"
@click="emit('approve', pendingApproval.pendingId)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
{{ t('chat.approve') }}
</button>
</div>
</div>
<!-- 输入区域(运行中也可输入) -->
<div v-else class="input-area-container">
<!-- 排队消息指示器 -->
<div v-if="queuedMessage && queuedMessage.status !== 'cancelled'" class="queued-indicator">
<div class="queued-indicator__info">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span class="queued-indicator__text">
{{ queuedMessage.status === 'sending' ? t('chat.queuedSending') : t('chat.queuedWillSend') }}
<span v-if="queueSize > 1" class="queued-indicator__count">({{ queueSize }})</span>
</span>
</div>
<button
v-if="queuedMessage.status === 'queued'"
type="button"
class="queued-indicator__cancel"
@click="emit('cancel-queued')"
>{{ t('chat.queuedCancel') }}</button>
</div>
<div class="input-area">
<textarea
ref="textareaRef"
v-model="inputValue"
class="chat-textarea"
:placeholder="inputPlaceholder"
:disabled="disabled"
:maxlength="maxLength"
rows="1"
@keydown.enter.exact.prevent="handleEnter"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
@focus="isFocused = true"
@blur="isFocused = false"
@input="autoResize"
@paste="handlePaste"
></textarea>
<div class="input-actions">
<!-- 附件按钮 -->
<button
v-if="enableAttachments"
type="button"
class="action-btn attach-btn"
:disabled="disabled || loading || uploading"
@click="openFilePicker"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.44 11.05l-8.49 8.49a6 6 0 0 1-8.49-8.49l9.2-9.19a4 4 0 1 1 5.66 5.66l-9.19 9.2a2 2 0 1 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<!-- 发送/停止/中断按钮 -->
<button
type="button"
class="action-btn send-btn"
:class="sendBtnClass"
:disabled="!canSend && !loading"
@click="handleSubmit"
>
<!-- 有输入时始终显示发送图标(运行中发送 = interrupt -->
<svg v-if="canSend" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
<!-- 运行中无输入:停止图标 -->
<svg v-else-if="loading" width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<rect x="3" y="3" width="18" height="18" rx="2"/>
</svg>
<!-- 空闲无输入 -->
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
<!-- 底部信息 -->
<div class="input-footer">
<span class="input-hint">{{ hint }}</span>
<span v-if="maxLength" class="input-length">
{{ inputValue.length }}/{{ maxLength }}
</span>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInputRef"
type="file"
class="hidden-file-input"
multiple
:accept="acceptedFileTypes"
@change="handleFileChange"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage } from '@/types'
interface Props {
/** 输入值 */
modelValue?: string
/** 占位符 */
placeholder?: string
/** 是否加载中AI 正在运行 */
loading?: boolean
/** 是否禁用无法输入 */
disabled?: boolean
/** 最大长度 */
maxLength?: number
/** 是否启用附件 */
enableAttachments?: boolean
/** 接受文件类型 */
acceptedFileTypes?: string
/** 提示文字 */
hint?: string
/** 附件列表 */
attachments?: ChatAttachment[]
/** 是否上传中 */
uploading?: boolean
/** 待审批数据存在时将输入框替换为审批栏 */
pendingApproval?: PendingApprovalMeta | null
/** 当前流阶段 */
streamPhase?: StreamPhase
/** 排队的消息队首 */
queuedMessage?: QueuedMessage | null
/** 排队消息总数 */
queueSize?: number
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: '',
loading: false,
disabled: false,
enableAttachments: true,
acceptedFileTypes: '*/*',
hint: '',
attachments: () => [],
uploading: false,
pendingApproval: null,
streamPhase: 'idle',
queuedMessage: null,
queueSize: 0,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
submit: [value: string]
stop: []
'cancel-queued': []
'file-select': [files: File[]]
'attachment-remove': [storedName: string]
approve: [pendingId: string]
deny: [pendingId: string]
}>()
const { t } = useI18n()
// 内部状态
const containerRef = ref<HTMLElement | null>(null)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const isFocused = ref(false)
const isComposing = ref(false)
// 输入值处理
const inputValue = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
// 是否可以发送
const canSend = computed(() => {
return inputValue.value.trim().length > 0 || props.attachments.length > 0
})
// 运行中输入的占位符
const inputPlaceholder = computed(() => {
if (props.loading) {
if (props.queuedMessage) return t('chat.queuedReplace')
return props.placeholder + ' (Enter to send / interrupt)'
}
return props.placeholder
})
// 处理提交
const handleSubmit = () => {
// 有排队消息时,点击按钮取消排队
if (props.queuedMessage && props.queuedMessage.status === 'queued') {
// 如果输入框为空,取消排队;如果有新输入,替换排队消息
if (!inputValue.value.trim()) {
emit('cancel-queued')
return
}
}
// 运行中且输入为空时,停止生成
if (props.loading && !canSend.value) {
emit('stop')
return
}
if (!canSend.value || props.disabled) return
// 运行中有内容发送useChat 会走 interrupt/queue 逻辑)
// 非运行中有内容:正常发送
emit('submit', inputValue.value)
}
// 发送按钮样式
const sendBtnClass = computed(() => ({
'is-loading': props.loading && !canSend.value,
'is-empty': !canSend.value && !props.loading,
'is-interrupt': props.loading && canSend.value,
}))
// 处理回车键
const handleEnter = () => {
if (isComposing.value) return
handleSubmit()
}
// 自动调整高度
const autoResize = () => {
nextTick(() => {
const textarea = textareaRef.value
if (!textarea) return
textarea.style.height = 'auto'
const newHeight = Math.min(textarea.scrollHeight, 160)
textarea.style.height = newHeight + 'px'
})
}
// 监听输入值变化,调整高度
watch(inputValue, autoResize)
// 文件处理
const openFilePicker = () => {
fileInputRef.value?.click()
}
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
if (files.length) {
emit('file-select', files)
}
// 清空 input 以便重复选择同一文件
input.value = ''
}
const removeAttachment = (storedName: string) => {
emit('attachment-remove', storedName)
}
// 粘贴处理
const handlePaste = (event: ClipboardEvent) => {
if (!props.enableAttachments) return
const items = Array.from(event.clipboardData?.items || [])
const files = items
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
if (files.length > 0) {
emit('file-select', files)
event.preventDefault()
}
}
// 文件大小格式化
const formatFileSize = (size: number) => {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
// 暴露方法给父组件
defineExpose({
focus: () => textareaRef.value?.focus(),
blur: () => textareaRef.value?.blur(),
clear: () => {
emit('update:modelValue', '')
nextTick(() => {
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
}
})
},
})
</script>
<style scoped>
.chat-input-wrapper {
padding: 18px 24px 22px;
background: var(--mc-bg-elevated, #f8fafc);
}
.chat-input-wrapper.is-focused {
/* focus state handled on .input-area */
}
/* 附件列表 */
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.attachment-chip {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
background: var(--mc-attachment-bg, #f1f5f9);
border: 1px solid var(--mc-attachment-border, #e2e8f0);
border-radius: 999px;
padding: 6px 8px 6px 12px;
}
.attachment-chip--image {
padding: 4px 6px;
}
.attachment-chip__thumbnail {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.attachment-chip__label {
display: inline-flex;
gap: 8px;
min-width: 0;
color: var(--mc-attachment-color, #1e293b);
text-decoration: none;
font-size: 13px;
}
.attachment-chip__label span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 280px;
}
.attachment-chip__label span:last-child {
flex-shrink: 0;
font-size: 12px;
color: var(--mc-primary, #D97757);
}
.attachment-chip__remove {
width: 22px;
height: 22px;
border: 0;
border-radius: 999px;
background: rgba(217, 119, 87, 0.16);
color: var(--mc-primary-hover, #C1572B);
cursor: pointer;
font-size: 16px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.attachment-chip__remove:hover {
background: rgba(217, 119, 87, 0.24);
}
.attachment-chip--dir {
border-style: dashed;
}
.attachment-chip__path {
font-size: 11px;
color: var(--mc-text-tertiary, #94a3b8);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 输入区域 */
.input-area {
display: flex;
gap: 10px;
align-items: flex-end;
background: var(--mc-input-bg, #ffffff);
border: none;
border-radius: 18px;
padding: 12px 12px 12px 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.15s;
}
.chat-input-wrapper.is-focused .input-area {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(217, 119, 87, 0.25);
}
.chat-textarea {
flex: 1;
border: none;
background: transparent;
resize: none;
outline: none;
font-size: 14px;
line-height: 1.6;
color: var(--mc-input-text, #1e293b);
min-height: 44px;
max-height: 160px;
font-family: inherit;
}
.chat-textarea::placeholder {
color: var(--mc-text-tertiary, #94a3b8);
}
.chat-textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 操作按钮 */
.input-actions {
display: flex;
gap: 8px;
align-items: center;
}
.action-btn {
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s;
background: transparent;
color: var(--mc-text-secondary, #64748b);
}
.action-btn:hover:not(:disabled) {
background: var(--mc-bg-sunken, #f1f5f9);
color: var(--mc-text-primary, #1e293b);
}
.action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.send-btn {
background: var(--mc-primary, #D97757);
color: white;
}
.send-btn:hover:not(:disabled) {
background: var(--mc-primary-hover, #C1572B);
}
.send-btn.is-loading {
background: var(--mc-danger, #ef4444);
}
.send-btn.is-loading:hover:not(:disabled) {
background: var(--mc-danger-hover, #dc2626);
}
.send-btn.is-empty:not(.is-loading) {
opacity: 0.4;
cursor: not-allowed;
}
/* 底部信息 */
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
padding: 0 4px;
}
.input-hint {
font-size: 12px;
color: var(--mc-text-tertiary, #94a3b8);
}
.input-length {
font-size: 12px;
color: var(--mc-text-tertiary, #94a3b8);
}
/* 隐藏的文件输入 */
.hidden-file-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
/* 审批栏 */
.approval-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--mc-input-bg, #ffffff);
border-radius: 18px;
padding: 10px 10px 10px 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(217, 119, 87, 0.3);
min-height: 56px;
}
.approval-bar__info {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
font-size: 14px;
color: var(--mc-text-secondary, #64748b);
}
.approval-bar__icon {
display: flex;
align-items: center;
color: var(--mc-primary, #D97757);
flex-shrink: 0;
}
.approval-bar__label {
flex-shrink: 0;
}
.approval-bar__tool {
font-weight: 600;
color: var(--mc-text-primary, #1e293b);
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 13px;
background: var(--mc-bg-sunken, #f1f5f9);
padding: 1px 7px;
border-radius: 5px;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
}
.approval-bar__actions {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.approval-bar__btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 14px;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
line-height: 1;
}
.approval-bar__btn--approve {
background: var(--mc-primary, #D97757);
color: #fff;
}
.approval-bar__btn--approve:hover {
background: var(--mc-primary-hover, #C1572B);
}
.approval-bar__btn--deny {
background: var(--mc-bg-sunken, #f1f5f9);
color: var(--mc-text-secondary, #64748b);
border: 1px solid var(--mc-border, #e2e8f0);
}
.approval-bar__btn--deny:hover {
background: #fee2e2;
color: #dc2626;
border-color: #fca5a5;
}
/* 输入区域容器 */
.input-area-container {
display: flex;
flex-direction: column;
gap: 0;
}
/* 排队指示器 */
.queued-indicator {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 16px;
margin-bottom: 4px;
border-radius: 10px;
background: rgba(59, 130, 246, 0.06);
border: 1px solid rgba(59, 130, 246, 0.15);
}
.queued-indicator__info {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #3b82f6;
}
.queued-indicator__text {
font-weight: 500;
}
.queued-indicator__cancel {
font-size: 12px;
font-weight: 500;
color: #64748b;
background: none;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 2px 8px;
cursor: pointer;
transition: all 0.15s;
}
.queued-indicator__cancel:hover {
color: #ef4444;
border-color: #fca5a5;
background: #fee2e2;
}
/* 中断发送按钮样式 */
.send-btn.is-interrupt {
background: #f59e0b;
color: white;
}
.send-btn.is-interrupt:hover:not(:disabled) {
background: #d97706;
}
/* ===== 移动端适配 ===== */
@media (max-width: 768px) {
.chat-input-wrapper {
padding: 10px 12px 14px;
}
.approval-bar {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 10px 12px;
}
.approval-bar__info {
flex-wrap: wrap;
}
.approval-bar__actions {
justify-content: flex-end;
}
.approval-bar__tool {
max-width: 180px;
}
.attachment-chip__label span:first-child {
max-width: 180px;
}
}
</style>