"use client"; import Image from "next/image"; import React, { useMemo } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { AnimatePresence, motion } from "framer-motion"; import { Avatar, Box, Button, Checkbox, Chip, CircularProgress, Collapse, FormControlLabel, IconButton, Paper, Stack, TextField, Tooltip, Typography, alpha, useTheme, } from "@mui/material"; import type { Theme } from "@mui/material/styles"; import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; import RefreshRounded from "@mui/icons-material/RefreshRounded"; import { TbArrowsSplit2 } from "react-icons/tb"; import { parseAssistantMessageSections, parseContentWithToolCalls, type ContentSegment, } from "./chatMessageSections"; import markdownStyles from "./GlobalChatboxMarkdown.module.css"; import type { Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { ChatInlineChart } from "./ChatInlineChart"; import type { ChatChartSeries } from "./ChatInlineChart"; import { ChatToolCallBlock } from "./ChatToolCallBlock"; import { AgentArtifactPanel } from "./AgentArtifactPanel"; import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; import PauseRounded from "@mui/icons-material/PauseRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import StopRounded from "@mui/icons-material/StopRounded"; import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; import TerminalRounded from "@mui/icons-material/TerminalRounded"; import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded"; import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; import BlockRounded from "@mui/icons-material/BlockRounded"; import PushPinRounded from "@mui/icons-material/PushPinRounded"; import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded"; import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded"; import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded"; import type { PermissionReply } from "@/lib/chatStream"; type AgentTurnProps = { message: Message; messageSpeechState: SpeechState; onSpeak: (messageId: string, text: string) => void; onPause: () => void; onResume: () => void; onStopSpeech: () => void; isTtsSupported: boolean; onRegenerate: (messageId: string) => void; onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void; onRejectQuestion: (requestId: string) => void; }; const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, ""); const MarkdownBlock = ({ children }: { children: string }) => { const handleCopy = React.useCallback((event: React.ClipboardEvent) => { const selectedText = window.getSelection()?.toString(); if (!selectedText) return; event.preventDefault(); event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText)); }, []); return (
{children}
); }; const formatMetadataValue = (value: unknown) => { if (typeof value === "string") { return value; } try { return JSON.stringify(value); } catch { return "[unserializable]"; } }; const truncateText = (value: string, maxLength: number) => value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; const formatMetadata = (metadata: Record) => { const entries = Object.entries(metadata) .filter(([key]) => !["command", "path", "file", "directory"].includes(key)) .slice(0, 3); if (!entries.length) { return ""; } return entries .map(([key, value]) => `${key}: ${truncateText(formatMetadataValue(value), 64)}`) .join(";"); }; const getPermissionTitle = (permission: NonNullable[number]) => { if (permission.permission === "external_directory") return "访问工作区外目录"; if (permission.permission === "bash") return "执行终端命令"; if (permission.permission === "edit") return "修改文件内容"; return permission.permission || "工具权限请求"; }; const getPermissionPrimaryValue = ( permission: NonNullable[number], ) => { const command = permission.metadata.command; if (typeof command === "string" && command.trim()) { return command.trim(); } for (const key of ["path", "file", "directory"]) { const value = permission.metadata[key]; if (typeof value === "string" && value.trim()) { return value.trim(); } } return permission.patterns[0] ?? permission.permission; }; const PermissionIcon = ({ permission, }: { permission: NonNullable[number]; }) => { if (permission.permission === "bash") { return ; } if (permission.permission === "external_directory") { return ; } return ; }; const getPermissionStatusLabel = (status: NonNullable[number]["status"]) => { if (status === "approved_always") return "已始终允许"; if (status === "approved_once") return "已允许一次"; if (status === "rejected") return "已拒绝"; if (status === "error") return "提交失败"; if (status === "submitting") return "提交中"; return "等待确认"; }; const pendingPermissionColor = "#f9a825"; const approvedOncePermissionColor = "#00838f"; const getPermissionStatusColor = ( status: NonNullable[number]["status"], theme: Theme, ) => { if (status === "approved_once") return approvedOncePermissionColor; if (status === "approved_always") return theme.palette.success.main; if (status === "rejected" || status === "error") return theme.palette.error.main; return pendingPermissionColor; }; const getPermissionStatusTextColor = ( status: NonNullable[number]["status"], theme: Theme, ) => { if (status === "approved_once") return "#006c78"; if (status === "approved_always") return theme.palette.success.dark; if (status === "rejected" || status === "error") return theme.palette.error.main; return "#8a5a00"; }; const PermissionRequestCard = ({ permission, onReply, }: { permission: NonNullable[number]; onReply: (requestId: string, reply: PermissionReply) => void; }) => { const theme = useTheme(); const isPending = permission.status === "pending" || permission.status === "error"; const isSubmitting = permission.status === "submitting"; const primaryValue = getPermissionPrimaryValue(permission); const metadataText = formatMetadata(permission.metadata); const accentColor = getPermissionStatusColor(permission.status, theme); const statusTextColor = getPermissionStatusTextColor(permission.status, theme); const statusLabel = getPermissionStatusLabel(permission.status); return ( {getPermissionTitle(permission)} 请求目标 {primaryValue} {metadataText ? ( {metadataText} ) : null} {permission.error ? ( {permission.error} ) : null} {isPending || isSubmitting ? ( ) : null} ); }; const PermissionRequestGroup = ({ permissions, isRunning, onReply, }: { permissions: NonNullable; isRunning: boolean; onReply: (requestId: string, reply: PermissionReply) => void; }) => { const theme = useTheme(); const onceCount = permissions.filter((permission) => permission.status === "approved_once").length; const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length; const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length; const pendingCount = permissions.length - onceCount - alwaysCount - rejectedCount; const hasPendingPermissions = pendingCount > 0; const [expanded, setExpanded] = React.useState(false); const latestPermissions = permissions.slice(-3); const pendingPermissions = permissions.filter( (permission) => permission.status === "pending" || permission.status === "submitting" || permission.status === "error", ); const summaryItems = [ { label: "共", value: permissions.length, color: theme.palette.text.secondary }, { label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) }, { label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) }, { label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) }, ]; const chipColor = pendingCount > 0 ? getPermissionStatusColor("pending", theme) : rejectedCount > 0 ? getPermissionStatusColor("rejected", theme) : getPermissionStatusColor("approved_always", theme); const chipTextColor = pendingCount > 0 ? getPermissionStatusTextColor("pending", theme) : rejectedCount > 0 ? getPermissionStatusTextColor("rejected", theme) : getPermissionStatusTextColor("approved_always", theme); return ( setExpanded((value) => !value)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setExpanded((value) => !value); } }} sx={{ px: 1.5, py: 1.15, cursor: "pointer", transition: "background-color 0.2s ease", "&:hover": { bgcolor: alpha("#000", 0.025) }, }} > 权限请求 {summaryItems.map((item) => ( {item.label} {item.value} 项 ))} {isRunning && pendingCount > 0 ? ( ) : null} {expanded ? ( ) : ( )} {!expanded && isRunning && !hasPendingPermissions && latestPermissions.length > 0 ? ( {latestPermissions.map((permission, index) => { const primaryValue = getPermissionPrimaryValue(permission); const isLast = index === latestPermissions.length - 1; const itemColor = getPermissionStatusColor(permission.status, theme); const itemTextColor = getPermissionStatusTextColor(permission.status, theme); return ( {getPermissionTitle(permission)} {truncateText(primaryValue, 72)} ); })} ) : null} {!expanded && isRunning && hasPendingPermissions ? ( {pendingPermissions.map((permission) => ( ))} ) : null} {permissions.map((permission) => ( ))} ); }; const getQuestionStatusLabel = ( status: NonNullable[number]["status"], ) => { if (status === "answered") return "已回答"; if (status === "rejected") return "已跳过"; if (status === "error") return "提交失败"; if (status === "submitting") return "提交中"; return "等待回答"; }; const getQuestionStatusColor = ( status: NonNullable[number]["status"], theme: Theme, ) => { if (status === "answered") return theme.palette.success.main; if (status === "rejected") return theme.palette.text.secondary; if (status === "error") return theme.palette.error.main; return "#0288d1"; }; const QuestionRequestCard = ({ questionRequest, onReply, onReject, }: { questionRequest: NonNullable[number]; onReply: (requestId: string, answers: string[][]) => void; onReject: (requestId: string) => void; }) => { const theme = useTheme(); const isEditable = questionRequest.status === "pending" || questionRequest.status === "error"; const isSubmitting = questionRequest.status === "submitting"; const statusColor = getQuestionStatusColor(questionRequest.status, theme); const [selected, setSelected] = React.useState>({}); const [customSelected, setCustomSelected] = React.useState>({}); const [custom, setCustom] = React.useState>({}); const answers = React.useMemo( () => questionRequest.questions.map((question, index) => { const selectedAnswers = selected[index] ?? []; const isCustomSelected = customSelected[index] === true || (question.custom !== false && question.options.length === 0); const customAnswer = custom[index]?.trim(); return isCustomSelected && customAnswer ? [...selectedAnswers, customAnswer] : selectedAnswers; }), [custom, customSelected, questionRequest.questions, selected], ); const canSubmit = isEditable && questionRequest.questions.length > 0 && questionRequest.questions.every((_, index) => { const answer = answers[index] ?? []; return answer.some((item) => item.trim().length > 0); }); const answerSummary = (questionRequest.answers ?? []) .map((answer) => answer.join("、")) .filter(Boolean) .join(";"); return ( 需要补充信息 {questionRequest.questions.map((question, index) => { const selectedAnswers = selected[index] ?? []; const isCustomEnabled = question.custom !== false; const isCustomSelected = customSelected[index] === true || (isCustomEnabled && question.options.length === 0); const setQuestionAnswers = (nextAnswers: string[]) => { setSelected((current) => ({ ...current, [index]: nextAnswers, })); }; const setQuestionCustomSelected = (checked: boolean) => { setCustomSelected((current) => ({ ...current, [index]: checked, })); }; return ( {question.header || `问题 ${index + 1}`} {question.question} {question.options.length ? ( {question.options.map((option) => { const checked = selectedAnswers.includes(option.label); if (question.multiple) { return ( { if (event.target.checked) { setQuestionAnswers([...selectedAnswers, option.label]); } else { setQuestionAnswers( selectedAnswers.filter((item) => item !== option.label), ); } }} /> } label={ {option.label} {option.description ? ( {option.description} ) : null} } sx={{ alignItems: "flex-start", m: 0 }} /> ); } return ( ); })} {isCustomEnabled ? ( question.multiple ? ( setQuestionCustomSelected(event.target.checked) } sx={{ p: 0.5, color: alpha("#0288d1", 0.55), "&.Mui-checked": { color: "#0288d1" }, }} /> } label={ 自定义回答 } sx={{ alignItems: "center", minHeight: 38, m: 0, px: 0.75, py: 0.25, borderRadius: 2, border: `1px solid ${ isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18) }`, bgcolor: isCustomSelected ? alpha("#0288d1", 0.1) : alpha("#fff", 0.45), transition: "background-color 0.18s ease, border-color 0.18s ease", "&:hover": { bgcolor: isCustomSelected ? alpha("#0288d1", 0.13) : alpha("#0288d1", 0.07), }, "& .MuiFormControlLabel-label": { color: isCustomSelected ? "#0277bd" : "text.primary", }, }} /> ) : ( ) ) : null} ) : null} setCustom((current) => ({ ...current, [index]: event.target.value, })) } placeholder="输入自定义回答" InputProps={{ disableUnderline: true, sx: { alignItems: "flex-start", fontSize: "0.88rem", lineHeight: 1.55, fontWeight: 500, color: "text.primary", "& textarea::placeholder": { color: alpha(theme.palette.text.primary, 0.38), opacity: 1, }, }, }} /> ); })} {questionRequest.status === "answered" ? ( 已回答{answerSummary ? `:${answerSummary}` : ""} ) : null} {questionRequest.status === "rejected" ? ( 已跳过 ) : null} {questionRequest.error ? ( {questionRequest.error} ) : null} {isEditable || isSubmitting ? ( ) : null} ); }; const QuestionRequestGroup = ({ questions, onReply, onReject, }: { questions: NonNullable; onReply: (requestId: string, answers: string[][]) => void; onReject: (requestId: string) => void; }) => ( {questions.map((question) => ( ))} ); const TodoPlanCard = ({ todoUpdate, }: { todoUpdate: NonNullable; }) => { const theme = useTheme(); const total = todoUpdate.todos.length; const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length; const running = todoUpdate.todos.find((todo) => todo.status === "in_progress"); const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length; const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length; const progress = total > 0 ? Math.round((completed / total) * 100) : 0; const isAborted = cancelled > 0 && completed + cancelled === total; const canCollapse = total > 4; const [expanded, setExpanded] = React.useState(!canCollapse && !isAborted); const pinnedTodos = canCollapse ? todoUpdate.todos.slice(0, 4) : todoUpdate.todos; const collapsibleTodos = canCollapse ? todoUpdate.todos.slice(4) : []; const hiddenCount = expanded ? 0 : collapsibleTodos.length; const latestUpdatedAt = Math.max( todoUpdate.createdAt, ...todoUpdate.todos .map((todo) => todo.updatedAt ?? todo.createdAt ?? 0) .filter((value) => value > 0), ); const updatedAtLabel = latestUpdatedAt > 0 ? new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", }).format(new Date(latestUpdatedAt)) : undefined; const getTodoVisual = (status: NonNullable["todos"][number]["status"]) => { if (status === "completed") { return { icon: , color: theme.palette.success.main, label: "完成" }; } if (status === "in_progress") { return { icon: , color: "#0288d1", label: "进行中" }; } if (status === "cancelled") { return { icon: , color: theme.palette.text.disabled, label: "中止" }; } return { icon: , color: theme.palette.text.secondary, label: "待办" }; }; const getPriorityLabel = (priority: NonNullable["todos"][number]["priority"]) => { if (priority === "high") return { label: "高优先级", color: "#8a5a00" }; if (priority === "medium") return { label: "中优先级", color: "#9a6a16" }; if (priority === "low") return { label: "低优先级", color: "#8d7960" }; return undefined; }; const statusSummary = isAborted ? `${completed} 完成 / ${cancelled} 中止` : [ completed ? `${completed} 完成` : null, running ? "1 进行中" : null, pending ? `${pending} 待办` : null, cancelled ? `${cancelled} 中止` : null, ].filter(Boolean).join(" / ") || "等待任务"; const renderTodoRow = ( todo: NonNullable["todos"][number], index: number, ) => { const visual = getTodoVisual(todo.status); const priority = getPriorityLabel(todo.priority); return ( {visual.icon} {todo.content} {priority ? ( ) : null} ); }; if (total === 0) { return null; } return ( { if (canCollapse) { setExpanded((value) => !value); } }} onKeyDown={(event) => { if (canCollapse && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); setExpanded((value) => !value); } }} sx={{ px: 1.4, py: 1.15, cursor: canCollapse ? "pointer" : "default", transition: "background-color 0.2s ease", "&:hover": canCollapse ? { bgcolor: alpha("#00838f", 0.035) } : undefined, }} > 会话任务 {statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""} {canCollapse ? ( {expanded ? ( ) : ( )} ) : null} {pinnedTodos.map((todo, index) => renderTodoRow(todo, index))} {canCollapse ? ( {collapsibleTodos.map((todo, index) => renderTodoRow(todo, index + pinnedTodos.length), )} ) : null} {hiddenCount > 0 ? ( 还有 {hiddenCount} 项,展开查看全部 ) : null} ); }; export const AgentTurn = React.memo( ({ message, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported, onRegenerate, onCreateBranch, onReplyPermission, onReplyQuestion, onRejectQuestion, }: AgentTurnProps) => { const theme = useTheme(); const isUser = message.role === "user"; const isErrorMessage = Boolean(message.isError); const [isHovered, setIsHovered] = React.useState(false); const isProgressComplete = message.progress?.some( (item) => item.phase === "complete" && item.status === "completed", ) ?? false; const isProgressRunning = !isErrorMessage && !isProgressComplete && ( message.progress?.some((item) => item.status === "running") ?? false ); const parsedAssistantSections = useMemo( () => !isUser && !isErrorMessage ? parseAssistantMessageSections(message.content) : null, [isErrorMessage, isUser, message.content], ); const answerContent = parsedAssistantSections?.answer ?? message.content; const contentSegments: ContentSegment[] = useMemo( () => !isUser && !isErrorMessage ? parseContentWithToolCalls(answerContent).segments : [{ type: "text", content: answerContent }], [answerContent, isErrorMessage, isUser], ); if (isUser) { return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {message.content} ); } return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > TJWater Agent {message.progress?.length ? ( ) : null} {message.permissions?.length ? ( ) : null} {message.questions?.length ? ( ) : null} {message.todos ? ( ) : null} 分析结果 {contentSegments.map((segment, segIdx) => { if (segment.type === "text") { const text = segment.content.trim(); if (!text && contentSegments.length > 1) return null; return {text || "..."}; } if (segment.type === "tool_call") { if ( segment.toolCall.tool === "chart" || segment.toolCall.tool === "show_chart" ) { const p = segment.toolCall.params; return ( ); } return ( ); } if (segment.type === "tool_call_pending") { return ( 正在准备工具调用... ); } return null; })} {isHovered && ( { navigator.clipboard.writeText( normalizeClipboardText(message.content), ); // Could add a toast here }} sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} > { onRegenerate(message.id); }} sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} > { onCreateBranch(message.id); }} sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} > )} {!isErrorMessage && isTtsSupported ? ( {messageSpeechState === "idle" ? ( onSpeak(message.id, stripMarkdown(answerContent))} aria-label="朗读消息" sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} > ) : null} {messageSpeechState === "playing" ? ( <> ) : null} {messageSpeechState === "paused" ? ( <> ) : null} ) : null} ); }, ); AgentTurn.displayName = "AgentTurn";