Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 85 additions & 36 deletions frontend/src/components/console/task/chat-inputbox.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useRef } from "react"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"
import { IconCommand, IconLoader, IconPlayerStopFilled, IconSend, IconTerminal2 } from "@tabler/icons-react"
import { IconArrowDown, IconArrowUp, IconCommand, IconLoader, IconPlayerStopFilled, IconSend, IconTerminal2, IconTrash } from "@tabler/icons-react"
import React from "react"
import { VoiceInputButton } from "./voice-input-button"
import type { TaskMessageHandlerStatus } from "@/components/console/task/task-message-handler"
Expand All @@ -10,21 +10,29 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { cn } from "@/lib/utils"


export interface QueuedUserInput {
id: string
content: string
}

interface TaskChatInputBoxProps {
streamStatus: TaskStreamStatus | TaskMessageHandlerStatus
availableCommands: AvailableCommands | null
onSend: (content: string) => void
sending: boolean
queueSize: number
queuedMessages?: QueuedUserInput[]
onMoveQueuedMessage?: (id: string, direction: "up" | "down") => void
onRemoveQueuedMessage?: (id: string) => void
executionTimeMs?: number
onCancel?: () => void
}

export const TaskChatInputBox = ({ streamStatus, availableCommands, onSend, sending, queueSize, executionTimeMs = 0, onCancel }: TaskChatInputBoxProps) => {
export const TaskChatInputBox = ({ streamStatus, availableCommands, onSend, sending, queueSize, queuedMessages = [], onMoveQueuedMessage, onRemoveQueuedMessage, executionTimeMs = 0, onCancel }: TaskChatInputBoxProps) => {
const [content, setContent] = useState('')
const [isComposing, setIsComposing] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isExecuting = (streamStatus === 'connected' || streamStatus === 'inited')
const isExecuting = (streamStatus === 'connected' || streamStatus === 'executing' || streamStatus === 'inited')

const handleSend = () => {
if (content.trim() === '') {
Expand All @@ -40,9 +48,6 @@ export const TaskChatInputBox = ({ streamStatus, availableCommands, onSend, send

// 处理键盘事件
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isExecuting) {
return
}
// 如果正在输入法组合过程中,不触发提交
if (isComposing) {
return
Expand All @@ -63,35 +68,68 @@ export const TaskChatInputBox = ({ streamStatus, availableCommands, onSend, send
setIsComposing(false)
}

const canInput = React.useMemo(() => {
return !sending && !isExecuting && queueSize === 0
}, [sending, isExecuting, queueSize])

const inputDisabled = React.useMemo(() => {
return !canInput
}, [canInput])

const controlsDisabled = React.useMemo(() => {
return !canInput
}, [canInput])
return sending || isExecuting || queueSize > 0
}, [sending, isExecuting, queueSize])

const commandItems = availableCommands?.commands ?? []
const showCommandItems = !isExecuting && commandItems.length > 0

return (
<div className="relative w-full">
<div className="relative flex w-full flex-col gap-2">
{queuedMessages.length > 0 && (
<div className="flex flex-col gap-1 rounded-md border bg-muted/30 p-2">
<div className="text-xs text-muted-foreground">待发送队列,从上往下依次发送</div>
{queuedMessages.map((message, index) => (
<div key={message.id} className="flex items-center gap-2 rounded border bg-background px-2 py-1.5 text-sm">
<div className="min-w-0 flex-1 truncate">{message.content}</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 shrink-0"
disabled={index === 0 || !onMoveQueuedMessage}
onClick={() => onMoveQueuedMessage?.(message.id, "up")}
aria-label="上移排队消息"
>
<IconArrowUp className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 shrink-0"
disabled={index === queuedMessages.length - 1 || !onMoveQueuedMessage}
onClick={() => onMoveQueuedMessage?.(message.id, "down")}
aria-label="下移排队消息"
>
<IconArrowDown className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 shrink-0"
disabled={!onRemoveQueuedMessage}
onClick={() => onRemoveQueuedMessage?.(message.id)}
aria-label="移除排队消息"
>
<IconTrash className="size-4" />
</Button>
</div>
))}
</div>
)}
<InputGroup>
{!isExecuting && (
<InputGroupTextarea
ref={textareaRef}
className="min-h-8 max-h-48 text-sm break-all"
placeholder="描述你的需求,Shift+Enter 换行,Enter 发送。"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd} />
)}
<InputGroupTextarea
ref={textareaRef}
className="min-h-8 max-h-48 text-sm break-all"
placeholder={isExecuting || sending ? "正在执行,输入内容将加入待发送队列。" : "描述你的需求,Shift+Enter 换行,Enter 发送。"}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd} />
<InputGroupAddon align="block-end" className="pb-1.5">
<div className="flex flex-row justify-between w-full">
<div className="flex flex-row gap-2 items-center min-w-0">
Expand Down Expand Up @@ -134,15 +172,26 @@ export const TaskChatInputBox = ({ streamStatus, availableCommands, onSend, send
disabled={controlsDisabled}
/>
)}
<InputGroupButton
className={cn("flex flex-row gap-2 items-center", isExecuting && "rounded-full")}
variant={isExecuting ? "destructive" : "default"}
size={isExecuting ? "icon-sm" : "sm"}
onClick={isExecuting ? onCancel : handleSend}
disabled={isExecuting ? !onCancel : content.trim() === '' || inputDisabled}
{isExecuting && onCancel && (
<Button
type="button"
variant="destructive"
size="icon-sm"
className="rounded-full"
onClick={onCancel}
>
<IconPlayerStopFilled />
</Button>
)}
<InputGroupButton
className={cn("flex flex-row gap-2 items-center", (isExecuting || sending) && "rounded-full")}
variant="default"
size="sm"
onClick={handleSend}
disabled={content.trim() === ''}
>
{isExecuting ? <IconPlayerStopFilled /> : <IconSend />}
{!isExecuting && "发送"}
<IconSend />
{isExecuting || sending || queueSize > 0 ? "排队" : "发送"}
</InputGroupButton>
</div>
</div>
Expand Down
63 changes: 60 additions & 3 deletions frontend/src/pages/console/user/task/task-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ConstsTaskStatus, type DomainProjectTask, type DomainVMPort } from "@/api/Api"
import { useBreadcrumbTask } from "@/components/console/breadcrumb-task-context"
import { PlanStepsBlock } from "@/components/console/task/chat-panel"
import { TaskChatInputBox } from "@/components/console/task/chat-inputbox"
import { TaskChatInputBox, type QueuedUserInput } from "@/components/console/task/chat-inputbox"
import { TaskControlClient } from "@/components/console/task/task-control-client"
import { TaskMessageHandler, type TaskMessageHandlerStatus } from "@/components/console/task/task-message-handler"
import { MessageItem, type MessageType } from "@/components/console/task/message"
Expand Down Expand Up @@ -58,6 +58,7 @@ export default function TaskDetailPage() {
used: null,
})
const [sending, setSending] = React.useState(false)
const [queuedUserInputs, setQueuedUserInputs] = React.useState<QueuedUserInput[]>([])
const [rawHistoryMessages, setRawHistoryMessages] = React.useState<MessageType[]>([])
const [rawLiveMessages, setRawLiveMessages] = React.useState<MessageType[]>([])
const [streamConnectionState, setStreamConnectionState] = React.useState<TaskStreamConnectionState>("closed")
Expand Down Expand Up @@ -198,6 +199,7 @@ export default function TaskDetailPage() {
const totalTokens = task?.stats?.total_tokens ?? ((task?.stats?.input_tokens ?? 0) + (task?.stats?.output_tokens ?? 0))
const hasContextUsage = contextUsage.size !== null || contextUsage.used !== null
const canInput = taskInteractive && !sending && streamStatus !== "connected" && streamStatus !== "inited"
const conversationBusy = sending || streamStatus === "connected" || streamStatus === "inited" || streamConnectionState === "connecting" || streamConnectionState === "reconnecting"
const planStreamStatus: TaskStreamStatus = streamStatus === "connected" ? "executing" : streamStatus
const contextProgress = contextUsage.size && contextUsage.size > 0
? Math.min(Math.max((contextUsage.used ?? 0) / contextUsage.size, 0), 1)
Expand Down Expand Up @@ -379,6 +381,7 @@ export default function TaskDetailPage() {
version: 0,
})
setSending(false)
setQueuedUserInputs([])
setRawHistoryMessages([])
setRawLiveMessages([])
setStreamConnectionState("closed")
Expand Down Expand Up @@ -574,10 +577,61 @@ export default function TaskDetailPage() {
fetchPortForwards()
}, [fetchPortForwards, previewDialogOpen, taskInteractive])

const handleSend = React.useCallback((content: string) => {
const sendUserInputNow = React.useCallback((content: string) => {
if (!taskId) return Promise.resolve(false)
return connectStreamClient("new", content)
}, [connectStreamClient, taskId])

const enqueueUserInput = React.useCallback((content: string) => {
const trimmedContent = content.trim()
if (!trimmedContent) return

setQueuedUserInputs((prev) => [
...prev,
{
id: `${Date.now()}-${Math.random()}`,
content,
},
])
}, [])

const handleSend = React.useCallback((content: string) => {
if (!taskId) return Promise.resolve(false)
if (conversationBusy || queuedUserInputs.length > 0) {
enqueueUserInput(content)
return Promise.resolve(true)
}

return sendUserInputNow(content)
}, [conversationBusy, enqueueUserInput, queuedUserInputs.length, sendUserInputNow, taskId])

const moveQueuedUserInput = React.useCallback((id: string, direction: "up" | "down") => {
setQueuedUserInputs((prev) => {
const index = prev.findIndex((message) => message.id === id)
if (index === -1) return prev

const nextIndex = direction === "up" ? index - 1 : index + 1
if (nextIndex < 0 || nextIndex >= prev.length) return prev

const next = [...prev]
const current = next[index]
next[index] = next[nextIndex]
next[nextIndex] = current
return next
})
}, [])

const removeQueuedUserInput = React.useCallback((id: string) => {
setQueuedUserInputs((prev) => prev.filter((message) => message.id !== id))
}, [])

React.useEffect(() => {
if (!taskInteractive || conversationBusy || queuedUserInputs.length === 0) return

const [nextMessage] = queuedUserInputs
setQueuedUserInputs((prev) => prev.slice(1))
void sendUserInputNow(nextMessage.content)
}, [conversationBusy, queuedUserInputs, sendUserInputNow, taskInteractive])
const messages = React.useMemo(() => {
const enhanceErrorMessage = (message: MessageType) => {
if (message.type !== "error_message") {
Expand Down Expand Up @@ -1042,7 +1096,10 @@ export default function TaskDetailPage() {
onSend={handleSend}
onCancel={handleCancel}
sending={sending}
queueSize={0}
queueSize={queuedUserInputs.length}
queuedMessages={queuedUserInputs}
onMoveQueuedMessage={moveQueuedUserInput}
onRemoveQueuedMessage={removeQueuedUserInput}
executionTimeMs={timeCost}
/>
) : (
Expand Down