Files
AstrBot/dashboard/src/components/chat/ChatInput.vue
Soulter 36d6f3b67e feat: add inline message editing and regeneration functionality for webui (#7673)
* feat: add inline message editing and regeneration functionality for webui

- Implemented inline editing for user messages in the chat component.
- Added a regenerate menu for retrying messages with different models.
- Enhanced message handling to include llm_checkpoint_id for better tracking.
- Updated localization files to include new actions for retrying and model selection.
- Introduced tests for checkpoint message handling and chat route functionality.

* feat: thread mode in webui

* feat: enhance message editing functionality to allow only the latest user message to be edited

* feat: add error handling and user feedback for thread creation in chat component

* feat: add thread count display and localization support in chat component

* feat: add RefsSidebar component and integrate reference management in chat UI

* feat: improve message editing validation and cleanup for bot messages

* feat: enhance checkpoint message handling with binding and dumping functionality
2026-04-22 11:51:12 +08:00

894 lines
21 KiB
Vue

<template>
<div
class="input-area fade-in"
:class="{ 'is-dark': isDark }"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<div
class="input-container"
:style="{
width: '85%',
maxWidth: '900px',
margin: '0 auto',
border: isDark ? 'none' : '1px solid #e0e0e0',
borderRadius: '24px',
boxShadow: isDark ? 'none' : '0px 2px 2px rgba(0, 0, 0, 0.1)',
backgroundColor: isDark ? '#2d2d2d' : 'transparent',
position: 'relative',
}"
>
<!-- 拖拽上传遮罩 -->
<transition name="fade">
<div v-if="isDragging" class="drop-overlay">
<div class="drop-overlay-content">
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
<span class="drop-text">{{ tm("input.dropToUpload") }}</span>
</div>
</div>
</transition>
<!-- 引用预览区 -->
<transition name="slideReply" @after-leave="handleReplyAfterLeave">
<div class="reply-preview" v-if="props.replyTo && !isReplyClosing">
<div class="reply-content">
<v-icon size="small" class="reply-icon">mdi-reply</v-icon>
"<span class="reply-text">{{ props.replyTo.selectedText }}</span
>"
</div>
<v-btn
@click="handleClearReply"
class="remove-reply-btn"
icon="mdi-close"
size="x-small"
color="grey"
variant="text"
/>
</div>
</transition>
<textarea
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
:disabled="disabled"
placeholder="Ask AstrBot..."
class="chat-textarea"
autocomplete="off"
autocorrect="off"
autocapitalize="sentences"
spellcheck="false"
style="
width: 100%;
resize: none;
outline: none;
border: 1px solid var(--v-theme-border);
border-radius: 12px;
padding: 12px 18px;
min-height: 34px;
max-height: 200px;
overflow-y: auto;
font-family: inherit;
font-size: 16px;
background-color: var(--v-theme-surface);
"
></textarea>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 14px;
"
>
<div
style="
display: flex;
justify-content: flex-start;
margin-top: 4px;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
overflow: hidden;
"
>
<!-- Settings Menu -->
<StyledMenu
offset="8"
location="top start"
:close-on-content-click="false"
>
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
icon="mdi-plus"
variant="outlined"
class="input-neutral-btn input-outline-control"
/>
</template>
<!-- Upload Files -->
<v-list-item
class="styled-menu-item"
rounded="md"
@click="triggerImageInput"
>
<template v-slot:prepend>
<v-icon icon="mdi-file-upload" size="small"></v-icon>
</template>
<v-list-item-title>
{{ tm("input.upload") }}
</v-list-item-title>
</v-list-item>
<!-- Config Selector in Menu -->
<ConfigSelector
:session-id="sessionId || null"
:platform-id="sessionPlatformId"
:is-group="sessionIsGroup"
:initial-config-id="props.configId"
@config-changed="handleConfigChange"
/>
<!-- Streaming Toggle in Menu -->
<v-list-item
class="styled-menu-item"
rounded="md"
@click="$emit('toggleStreaming')"
>
<template v-slot:prepend>
<v-icon icon="mdi-lightning-bolt" size="small"></v-icon>
</template>
<v-list-item-title>
{{
enableStreaming
? tm("streaming.enabled")
: tm("streaming.disabled")
}}
</v-list-item-title>
</v-list-item>
</StyledMenu>
<!-- Provider/Model Selector Menu -->
<ProviderModelMenu
v-if="showProviderSelector"
ref="providerModelMenuRef"
/>
</div>
<div
style="
display: flex;
justify-content: flex-end;
margin-top: 8px;
align-items: center;
flex-shrink: 0;
"
>
<input
type="file"
ref="imageInputRef"
@change="handleFileSelect"
style="display: none"
multiple
/>
<v-progress-circular
v-if="disabled && !mobile"
indeterminate
size="16"
class="mr-1"
width="1.5"
/>
<!-- <v-btn @click="$emit('openLiveMode')"
icon
variant="text"
color="purple"
size="small"
>
<v-icon icon="mdi-phone-in-talk" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ tm('voice.liveMode') }}
</v-tooltip>
</v-btn> -->
<v-btn
@click="handleRecordClick"
icon
variant="text"
class="record-btn input-icon-btn"
>
<v-icon
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'"
variant="text"
plain
></v-icon>
<v-tooltip activator="parent" location="top">
{{
isRecording ? tm("voice.speaking") : tm("voice.startRecording")
}}
</v-tooltip>
</v-btn>
<v-btn
icon
v-if="isRunning && !canSend"
@click="$emit('stop')"
variant="tonal"
class="send-btn input-action-btn"
>
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ tm("input.stopGenerating") }}
</v-tooltip>
</v-btn>
<v-btn
v-else
@click="$emit('send')"
icon="mdi-arrow-up"
variant="tonal"
:disabled="!canSend"
class="send-btn input-action-btn"
/>
</div>
</div>
</div>
<!-- 附件预览区 -->
<div
class="attachments-preview"
v-if="
stagedImagesUrl.length > 0 ||
stagedAudioUrl ||
(stagedFiles && stagedFiles.length > 0)
"
>
<div
v-for="(img, index) in stagedImagesUrl"
:key="'img-' + index"
class="image-preview"
>
<img :src="img" class="preview-image" />
<v-btn
@click="$emit('removeImage', index)"
class="remove-attachment-btn"
icon="mdi-close"
size="small"
color="error"
variant="text"
/>
</div>
<div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="primary" variant="tonal" class="audio-chip">
<v-icon start icon="mdi-microphone" size="small"></v-icon>
{{ tm("voice.recording") }}
</v-chip>
<v-btn
@click="$emit('removeAudio')"
class="remove-attachment-btn"
icon="mdi-close"
size="small"
color="error"
variant="text"
/>
</div>
<div
v-for="(file, index) in stagedFiles"
:key="'file-' + index"
class="file-preview"
>
<v-chip color="primary" variant="tonal" class="file-chip">
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
<span class="file-name-preview">{{ file.original_name }}</span>
</v-chip>
<v-btn
@click="$emit('removeFile', index)"
class="remove-attachment-btn"
icon="mdi-close"
size="small"
color="error"
variant="text"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref,
computed,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from "vue";
import { useDisplay } from "vuetify";
import { useModuleI18n } from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
import ConfigSelector from "./ConfigSelector.vue";
import ProviderModelMenu from "./ProviderModelMenu.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import type { Session } from "@/composables/useSessions";
interface StagedFileInfo {
attachment_id: string;
filename: string;
original_name: string;
url: string;
type: string;
}
interface ReplyInfo {
messageId: string | number;
selectedText?: string;
}
interface Props {
prompt: string;
stagedImagesUrl: string[];
stagedAudioUrl: string;
stagedFiles?: StagedFileInfo[];
disabled: boolean;
enableStreaming: boolean;
isRecording: boolean;
isRunning: boolean;
sessionId?: string | null;
currentSession?: Session | null;
configId?: string | null;
replyTo?: ReplyInfo | null;
sendShortcut?: "enter" | "shift_enter";
}
const props = withDefaults(defineProps<Props>(), {
sessionId: null,
currentSession: null,
configId: null,
stagedFiles: () => [],
replyTo: null,
sendShortcut: "shift_enter",
});
const emit = defineEmits<{
"update:prompt": [value: string];
send: [];
stop: [];
toggleStreaming: [];
removeImage: [index: number];
removeAudio: [];
removeFile: [index: number];
startRecording: [];
stopRecording: [];
pasteImage: [event: ClipboardEvent];
fileSelect: [files: FileList];
clearReply: [];
openLiveMode: [];
}>();
const { tm } = useModuleI18n("features/chat");
const isDark = computed(
() => useCustomizerStore().uiTheme === "PurpleThemeDark",
);
const inputField = ref<HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(
null,
);
const showProviderSelector = ref(true);
const isReplyClosing = ref(false);
const isDragging = ref(false);
let dragLeaveTimeout: number | null = null;
const localPrompt = computed({
get: () => props.prompt,
set: (value) => emit("update:prompt", value),
});
const sessionPlatformId = computed(
() => props.currentSession?.platform_id || "webchat",
);
const sessionIsGroup = computed(() => Boolean(props.currentSession?.is_group));
const canSend = computed(() => {
return (
(props.prompt && props.prompt.trim()) ||
props.stagedImagesUrl.length > 0 ||
props.stagedAudioUrl ||
(props.stagedFiles && props.stagedFiles.length > 0)
);
});
// Ctrl+B 长按录音相关
const ctrlKeyDown = ref(false);
const ctrlKeyTimer = ref<number | null>(null);
const ctrlKeyLongPressThreshold = 300;
// 处理清除引用 - 触发关闭动画
function handleClearReply() {
isReplyClosing.value = true;
}
// 动画完成后发送clearReply事件
function handleReplyAfterLeave() {
emit("clearReply");
isReplyClosing.value = false;
}
const { mobile } = useDisplay();
// Auto-resize textarea
function autoResize() {
const el = inputField.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 200) + "px";
}
watch(localPrompt, () => {
nextTick(autoResize);
});
function handleKeyDown(e: KeyboardEvent) {
const isEnter = e.key === "Enter";
if (!isEnter) {
// Ctrl+B 录音
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit("startRecording");
}
}, ctrlKeyLongPressThreshold);
}
return;
}
const isSendHotkey =
e.ctrlKey ||
e.metaKey ||
(props.sendShortcut === "enter" ? !e.shiftKey : e.shiftKey);
if (isSendHotkey) {
e.preventDefault();
if (localPrompt.value.trim() === "/astr_live_dev") {
emit("openLiveMode");
localPrompt.value = "";
return;
}
if (canSend.value) {
emit("send");
}
return;
}
}
function handleKeyUp(e: KeyboardEvent) {
if (e.keyCode === 66) {
ctrlKeyDown.value = false;
if (ctrlKeyTimer.value) {
clearTimeout(ctrlKeyTimer.value);
ctrlKeyTimer.value = null;
}
if (props.isRecording) {
emit("stopRecording");
}
}
}
function handlePaste(e: ClipboardEvent) {
emit("pasteImage", e);
}
function handleDragOver(e: DragEvent) {
// 清除之前的 leave timeout
if (dragLeaveTimeout) {
clearTimeout(dragLeaveTimeout);
dragLeaveTimeout = null;
}
// 检查是否有文件
if (e.dataTransfer?.types.includes("Files")) {
isDragging.value = true;
}
}
function handleDragLeave(e: DragEvent) {
// 使用 timeout 避免在子元素间移动时闪烁
dragLeaveTimeout = window.setTimeout(() => {
isDragging.value = false;
}, 50);
}
function handleDrop(e: DragEvent) {
isDragging.value = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
emit("fileSelect", files);
}
}
function triggerImageInput() {
imageInputRef.value?.click();
}
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const files = target.files;
if (files) {
emit("fileSelect", files);
}
target.value = "";
}
function handleRecordClick() {
if (props.isRecording) {
emit("stopRecording");
} else {
emit("startRecording");
}
}
function handleConfigChange(payload: {
configId: string;
agentRunnerType: string;
}) {
const runnerType = (payload.agentRunnerType || "").toLowerCase();
const isInternal = runnerType === "internal" || runnerType === "local";
showProviderSelector.value = isInternal;
}
function getCurrentSelection() {
if (!showProviderSelector.value) {
return null;
}
return providerModelMenuRef.value?.getCurrentSelection();
}
function focusInput() {
if (!inputField.value) return;
inputField.value.focus();
}
onMounted(() => {
if (inputField.value) {
inputField.value.addEventListener("paste", handlePaste);
}
document.addEventListener("keyup", handleKeyUp);
});
onBeforeUnmount(() => {
if (inputField.value) {
inputField.value.removeEventListener("paste", handlePaste);
}
document.removeEventListener("keyup", handleKeyUp);
});
defineExpose({
getCurrentSelection,
focusInput,
});
</script>
<style scoped>
.input-area {
padding: 12px 16px 0;
background-color: transparent;
position: relative;
border-top: 1px solid var(--v-theme-border);
flex-shrink: 0;
}
.input-neutral-btn {
color: #6f6f6f !important;
}
.input-neutral-btn:hover {
background: #efefef;
}
.input-neutral-btn--tonal {
background: #efefef;
color: #4f4f4f !important;
}
.input-neutral-btn--tonal:hover {
background: #e7e7e7;
}
.input-action-btn {
background: #5594c6 !important;
color: #fff !important;
}
.input-action-btn:hover {
background: #4c86b3 !important;
}
.input-action-btn:disabled {
background: rgba(85, 148, 198, 0.24) !important;
color: rgba(255, 255, 255, 0.72) !important;
}
.input-icon-btn {
background: transparent !important;
color: rgb(var(--v-theme-on-surface)) !important;
margin-right: 8px;
}
.input-icon-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.04) !important;
}
.input-outline-control {
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
border-color: rgba(var(--v-theme-on-surface), 0.18) !important;
background: transparent !important;
}
.input-outline-control:hover {
border-color: rgba(var(--v-theme-on-surface), 0.34) !important;
background: rgba(var(--v-theme-on-surface), 0.04) !important;
}
.input-area.is-dark .input-neutral-btn {
color: rgba(255, 255, 255, 0.78) !important;
}
.input-area.is-dark .input-neutral-btn:hover,
.input-area.is-dark .input-neutral-btn--tonal {
background: rgba(255, 255, 255, 0.1);
}
.input-area.is-dark .input-outline-control {
border-color: rgba(255, 255, 255, 0.22) !important;
background: transparent !important;
}
.input-area.is-dark .input-outline-control:hover {
border-color: rgba(255, 255, 255, 0.42) !important;
background: rgba(255, 255, 255, 0.06) !important;
}
.input-area.is-dark .input-action-btn {
background: rgb(var(--v-theme-on-surface)) !important;
color: rgb(var(--v-theme-surface)) !important;
}
.input-area.is-dark .input-action-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.86) !important;
}
.input-area.is-dark .input-action-btn:disabled {
background: rgba(var(--v-theme-on-surface), 0.14) !important;
color: rgba(var(--v-theme-on-surface), 0.4) !important;
}
/* 拖拽上传遮罩 */
.drop-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(var(--v-theme-primary), 0.12);
border: 2px dashed rgba(var(--v-theme-primary), 0.45);
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
pointer-events: none;
}
.drop-overlay-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.drop-text {
font-size: 16px;
font-weight: 500;
color: rgb(var(--v-theme-primary));
}
/* Fade transition for drop overlay */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.reply-preview {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
margin: 8px 8px 0 8px;
background-color: rgba(var(--v-theme-primary), 0.06);
border-radius: 12px;
gap: 8px;
max-height: 500px;
overflow: hidden;
}
/* Transition animations for reply preview */
.slideReply-enter-active {
animation: slideDown 0.2s ease-out;
}
.slideReply-leave-active {
animation: slideUp 0.2s ease-out;
}
@keyframes slideDown {
from {
max-height: 0;
opacity: 0;
margin-top: 0;
padding-top: 0;
padding-bottom: 0;
}
to {
max-height: 500px;
opacity: 1;
margin-top: 8px;
padding-top: 8px;
padding-bottom: 8px;
}
}
@keyframes slideUp {
from {
max-height: 500px;
opacity: 1;
margin-top: 8px;
padding-top: 8px;
padding-bottom: 8px;
}
to {
max-height: 0;
opacity: 0;
margin-top: 0;
padding-top: 0;
padding-bottom: 0;
}
}
.reply-content {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.reply-icon {
color: var(--v-theme-secondary);
flex-shrink: 0;
}
.reply-text {
font-size: 13px;
color: var(--v-theme-secondaryText);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.remove-reply-btn {
flex-shrink: 0;
opacity: 0.6;
}
.attachments-preview {
display: flex;
gap: 8px;
margin-top: 8px;
max-width: 900px;
margin: 8px auto 0;
flex-wrap: wrap;
}
.image-preview,
.audio-preview,
.file-preview {
position: relative;
display: inline-flex;
}
.preview-image {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.audio-chip,
.file-chip {
height: 36px;
border-radius: 18px;
}
.file-name-preview {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-attachment-btn {
position: absolute;
top: -8px;
right: -8px;
opacity: 0.8;
transition: opacity 0.2s;
}
.remove-attachment-btn:hover {
opacity: 1;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.input-area {
padding: 0 !important;
}
.input-container {
width: 100% !important;
max-width: 100% !important;
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.input-outline-control {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
}
.input-area textarea,
.chat-textarea {
min-height: 28px !important;
max-height: 140px !important;
font-size: 16px !important;
line-height: 20px !important;
padding: 8px 14px 7px !important;
}
}
</style>