支持会话响应时添加队列消息

This commit is contained in:
xintaofei
2026-03-12 23:19:24 +08:00
parent 6c26d067fd
commit 1f623c0d6e
16 changed files with 566 additions and 31 deletions

View File

@@ -13,7 +13,16 @@ import {
PopoverTrigger,
} from "@/components/ui/popover"
import { Textarea } from "@/components/ui/textarea"
import { Ellipsis, FileSearch, Plus, Send, Square, X } from "lucide-react"
import {
Check,
Ellipsis,
FileSearch,
ListPlus,
Plus,
Send,
Square,
X,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
@@ -62,6 +71,11 @@ interface MessageInputProps {
attachmentTabId?: string | null
draftStorageKey?: string | null
isActive?: boolean
onEnqueue?: (draft: PromptDraft, modeId: string | null) => void
editingDraftText?: string | null
isEditingQueueItem?: boolean
onSaveQueueEdit?: (draft: PromptDraft) => void
onCancelQueueEdit?: () => void
}
interface ResourceInputAttachment {
@@ -261,8 +275,14 @@ export function MessageInput({
attachmentTabId,
draftStorageKey,
isActive = false,
onEnqueue,
editingDraftText,
isEditingQueueItem = false,
onSaveQueueEdit,
onCancelQueueEdit,
}: MessageInputProps) {
const t = useTranslations("Folder.chat.messageInput")
const tQueue = useTranslations("Folder.chat.messageQueue")
const { shortcuts } = useShortcutSettings()
const effectiveDraftStorageKey = draftStorageKey ?? attachmentTabId ?? null
const resolvedPlaceholder = placeholder ?? t("askAnything")
@@ -302,6 +322,24 @@ export function MessageInput({
isPromptingRef.current = isPrompting
}, [isPrompting])
// Load external draft text when editing a queue item
const prevEditingDraftRef = useRef<string | null>(null)
useEffect(() => {
if (
isEditingQueueItem &&
editingDraftText != null &&
editingDraftText !== prevEditingDraftRef.current
) {
prevEditingDraftRef.current = editingDraftText
setText(editingDraftText)
requestAnimationFrame(() => {
textareaRef.current?.focus()
})
} else if (!isEditingQueueItem) {
prevEditingDraftRef.current = null
}
}, [isEditingQueueItem, editingDraftText])
const setDragActiveIfChanged = useCallback((next: boolean) => {
if (dragActiveRef.current === next) return
dragActiveRef.current = next
@@ -309,9 +347,9 @@ export function MessageInput({
}, [])
useEffect(() => {
if (!effectiveDraftStorageKey) return
if (!effectiveDraftStorageKey || isEditingQueueItem) return
saveMessageInputDraft(effectiveDraftStorageKey, text)
}, [effectiveDraftStorageKey, text])
}, [effectiveDraftStorageKey, text, isEditingQueueItem])
const availableModes = useMemo(() => modes ?? [], [modes])
const availableConfigOptions = useMemo(
@@ -653,7 +691,7 @@ export function MessageInput({
const handlePaste = useCallback(
(event: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (disabled || isPrompting) return
if (disabled) return
const files = Array.from(event.clipboardData?.files ?? [])
if (files.length === 0) return
event.preventDefault()
@@ -661,7 +699,7 @@ export function MessageInput({
console.error("[MessageInput] paste files failed:", error)
})
},
[appendFilesFromInput, disabled, isPrompting]
[appendFilesFromInput, disabled]
)
useEffect(() => {
@@ -763,15 +801,13 @@ export function MessageInput({
if (payload.type === "drop") {
setDragActiveIfChanged(false)
if (Date.now() - lastDomDropAtRef.current < 250) return
if (!inside || disabledRef.current || isPromptingRef.current) return
if (!inside || disabledRef.current) return
void appendPathsFromDropRef.current(payload.paths).catch((error) => {
console.error("[MessageInput] drag drop paths failed:", error)
})
return
}
setDragActiveIfChanged(
inside && !disabledRef.current && !isPromptingRef.current
)
setDragActiveIfChanged(inside && !disabledRef.current)
}
const setup = async () => {
@@ -843,9 +879,9 @@ export function MessageInput({
setAttachments((prev) => prev.filter((item) => item.id !== id))
}, [])
const handleSend = useCallback(() => {
const buildDraft = useCallback((): PromptDraft | null => {
const trimmed = textRef.current.trim()
if (!trimmed && attachments.length === 0) return
if (!trimmed && attachments.length === 0) return null
const blocks: PromptInputBlock[] = []
if (trimmed) {
@@ -883,14 +919,41 @@ export function MessageInput({
const displayText =
trimmed ||
`Attached ${attachments.length} attachment${attachments.length > 1 ? "s" : ""}`
onSend({ blocks, displayText }, showModeSelector ? effectiveModeId : null)
return { blocks, displayText }
}, [attachments])
const handleSend = useCallback(() => {
const draft = buildDraft()
if (!draft) return
// Edit mode: save back to queue item
if (isEditingQueueItem && onSaveQueueEdit) {
onSaveQueueEdit(draft)
setText("")
setAttachments([])
return
}
// Prompting mode: enqueue instead of sending
if (isPrompting && onEnqueue) {
onEnqueue(draft, showModeSelector ? effectiveModeId : null)
setText("")
setAttachments([])
return
}
onSend(draft, showModeSelector ? effectiveModeId : null)
if (effectiveDraftStorageKey) {
clearMessageInputDraft(effectiveDraftStorageKey)
}
setText("")
setAttachments([])
}, [
attachments,
buildDraft,
isEditingQueueItem,
isPrompting,
onSaveQueueEdit,
onEnqueue,
onSend,
effectiveModeId,
showModeSelector,
@@ -935,9 +998,15 @@ export function MessageInput({
}
}
if (isEditingQueueItem && e.key === "Escape") {
e.preventDefault()
onCancelQueueEdit?.()
return
}
if (matchShortcutEvent(e, shortcuts.send_message)) {
e.preventDefault()
if (!disabled) handleSend()
if (!disabled || isPrompting || isEditingQueueItem) handleSend()
} else if (matchShortcutEvent(e, shortcuts.newline_in_message)) {
e.preventDefault()
const textarea = e.currentTarget as HTMLTextAreaElement
@@ -953,6 +1022,9 @@ export function MessageInput({
},
[
disabled,
isPrompting,
isEditingQueueItem,
onCancelQueueEdit,
handleSend,
shortcuts,
slashMenuOpen,
@@ -966,11 +1038,11 @@ export function MessageInput({
(event: React.DragEvent<HTMLDivElement>) => {
if (!hasDragFiles(event.dataTransfer)) return
event.preventDefault()
if (!disabled && !isPrompting) {
if (!disabled) {
setDragActiveIfChanged(true)
}
},
[disabled, isPrompting, setDragActiveIfChanged]
[disabled, setDragActiveIfChanged]
)
const handleContainerDragLeave = useCallback(
@@ -994,7 +1066,7 @@ export function MessageInput({
event.preventDefault()
lastDomDropAtRef.current = Date.now()
setDragActiveIfChanged(false)
if (disabled || isPrompting) return
if (disabled) return
const files = Array.from(event.dataTransfer.files ?? [])
if (files.length > 0) {
void appendFilesFromInput(files).catch((error) => {
@@ -1002,7 +1074,7 @@ export function MessageInput({
})
}
},
[appendFilesFromInput, disabled, isPrompting, setDragActiveIfChanged]
[appendFilesFromInput, disabled, setDragActiveIfChanged]
)
const hasImageAttachments = imageAttachments.length > 0
@@ -1016,7 +1088,7 @@ export function MessageInput({
? "pt-10"
: "pt-3"
const bottomPaddingClass = "pb-10"
const showDragActive = isDragActive && !disabled && !isPrompting
const showDragActive = isDragActive && !disabled
const selectorItems = (
<>
@@ -1143,7 +1215,7 @@ export function MessageInput({
<div className="flex items-center gap-1">
<Button
onClick={handlePickFiles}
disabled={disabled || isPrompting}
disabled={disabled}
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
@@ -1175,16 +1247,47 @@ export function MessageInput({
)}
</div>
</div>
{isPrompting && onCancel ? (
<Button
onClick={onCancel}
variant="destructive"
size="icon"
className="absolute right-2 bottom-2"
title={t("cancel")}
>
<Square className="h-4 w-4" />
</Button>
{isEditingQueueItem ? (
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<Button
onClick={onCancelQueueEdit}
variant="ghost"
size="icon"
className="h-8 w-8"
title={tQueue("cancelEdit")}
>
<X className="h-4 w-4" />
</Button>
<Button
onClick={handleSend}
disabled={!hasSendableContent}
size="icon"
title={tQueue("saveEdit")}
>
<Check className="h-4 w-4" />
</Button>
</div>
) : isPrompting && onCancel ? (
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<Button
onClick={handleSend}
disabled={!hasSendableContent}
variant="secondary"
size="icon"
className="h-8 w-8"
title={tQueue("addToQueue")}
>
<ListPlus className="h-4 w-4" />
</Button>
<Button
onClick={onCancel}
variant="destructive"
size="icon"
title={t("cancel")}
>
<Square className="h-4 w-4" />
</Button>
</div>
) : (
<Button
onClick={handleSend}