diff --git a/src/components/chat/AgentMarkdownBlock.tsx b/src/components/chat/AgentMarkdownBlock.tsx new file mode 100644 index 0000000..b95c4fe --- /dev/null +++ b/src/components/chat/AgentMarkdownBlock.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import markdownStyles from "./GlobalChatboxMarkdown.module.css"; + +export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, ""); + +export 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} +
+ ); +}; + + diff --git a/src/components/chat/AgentPermissionRequests.tsx b/src/components/chat/AgentPermissionRequests.tsx new file mode 100644 index 0000000..cf67a8e --- /dev/null +++ b/src/components/chat/AgentPermissionRequests.tsx @@ -0,0 +1,617 @@ +"use client"; + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { + Box, + Button, + Chip, + CircularProgress, + Collapse, + IconButton, + Stack, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import type { Theme } from "@mui/material/styles"; +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 KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; +import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; +import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; + +import type { PermissionReply } from "@/lib/chatStream"; +import type { Message } from "./GlobalChatbox.types"; + +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} + + ); +}; + +export 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) => ( + + ))} + + + + ); +}; + + diff --git a/src/components/chat/AgentQuestionRequests.tsx b/src/components/chat/AgentQuestionRequests.tsx new file mode 100644 index 0000000..0698a56 --- /dev/null +++ b/src/components/chat/AgentQuestionRequests.tsx @@ -0,0 +1,564 @@ +"use client"; + +import React from "react"; +import { + Box, + Button, + Checkbox, + Chip, + CircularProgress, + Collapse, + FormControlLabel, + Stack, + TextField, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import type { Theme } from "@mui/material/styles"; +import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; +import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; +import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded"; +import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded"; + +import type { Message } from "./GlobalChatbox.types"; + +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} + + ); +}; + +export const QuestionRequestGroup = ({ + questions, + onReply, + onReject, +}: { + questions: NonNullable; + onReply: (requestId: string, answers: string[][]) => void; + onReject: (requestId: string) => void; +}) => ( + + {questions.map((question) => ( + + ))} + +); + + diff --git a/src/components/chat/AgentTodoPlanCard.tsx b/src/components/chat/AgentTodoPlanCard.tsx new file mode 100644 index 0000000..6461ff2 --- /dev/null +++ b/src/components/chat/AgentTodoPlanCard.tsx @@ -0,0 +1,308 @@ +"use client"; + +import React from "react"; +import { + Box, + Chip, + CircularProgress, + Collapse, + IconButton, + Stack, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded"; +import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; +import BlockRounded from "@mui/icons-material/BlockRounded"; +import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; +import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; +import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded"; + +import type { Message } from "./GlobalChatbox.types"; + +export 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} + + + ); +}; + + diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 0597475..4acf1e9 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -2,62 +2,41 @@ 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 type { PermissionReply } from "@/lib/chatStream"; 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 { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock"; +import { PermissionRequestGroup } from "./AgentPermissionRequests"; +import { QuestionRequestGroup } from "./AgentQuestionRequests"; +import { TodoPlanCard } from "./AgentTodoPlanCard"; 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; @@ -74,1433 +53,6 @@ type AgentTurnProps = { 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, diff --git a/src/components/chat/hooks/agentChatSessionState.ts b/src/components/chat/hooks/agentChatSessionState.ts new file mode 100644 index 0000000..56d1699 --- /dev/null +++ b/src/components/chat/hooks/agentChatSessionState.ts @@ -0,0 +1,457 @@ +import type { + AgentQuestionRequest, + AgentTodoUpdate, + PermissionReply, + StreamEvent, +} from "@/lib/chatStream"; +import type { + AgentPermissionRequest, + ChatProgress, + LoadedChatState, + Message, +} from "../GlobalChatbox.types"; +import { createId } from "../GlobalChatbox.utils"; + +export const createPersistedStateKey = (state: LoadedChatState) => + JSON.stringify({ + title: state.title ?? null, + isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, + sessionId: state.sessionId ?? null, + messages: state.messages, + }); + +export const upsertProgress = ( + progress: ChatProgress[] | undefined, + event: StreamEvent & { type: "progress" }, +) => { + const next = [...(progress ?? [])]; + const index = next.findIndex((item) => item.id === event.id); + const existing = index >= 0 ? next[index] : undefined; + const now = Date.now(); + const startedAt = event.startedAt ?? existing?.startedAt; + const isRunning = event.status === "running"; + const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now; + const elapsedMs = isRunning + ? event.elapsedMs ?? + existing?.elapsedMs ?? + (startedAt !== undefined ? Math.max(0, now - startedAt) : undefined) + : undefined; + const elapsedSnapshotAt = isRunning + ? event.elapsedMs !== undefined + ? now + : existing?.elapsedSnapshotAt ?? now + : undefined; + const durationMs = !isRunning + ? event.durationMs ?? + existing?.durationMs ?? + (startedAt !== undefined && endedAt !== undefined + ? Math.max(0, endedAt - startedAt) + : undefined) + : undefined; + const nextItem: ChatProgress = { + id: event.id, + phase: event.phase, + status: event.status, + title: event.title, + detail: event.detail, + startedAt, + endedAt, + elapsedMs, + elapsedSnapshotAt, + durationMs, + }; + if (index >= 0) { + next[index] = nextItem; + } else { + next.push(nextItem); + } + return next; +}; + +export const completeRunningProgress = (progress: ChatProgress[] | undefined) => + progress?.map((item) => { + if (item.status !== "running") { + return item; + } + const endedAt = Date.now(); + return { + ...item, + status: "completed" as const, + endedAt, + elapsedMs: undefined, + elapsedSnapshotAt: undefined, + durationMs: + item.durationMs ?? + (item.startedAt !== undefined + ? Math.max(0, endedAt - item.startedAt) + : item.elapsedMs), + }; + }); + +export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) => + todoUpdate + ? { + ...todoUpdate, + todos: todoUpdate.todos.map((todo) => + todo.status === "pending" || todo.status === "in_progress" + ? { + ...todo, + status: "cancelled" as const, + updatedAt: Date.now(), + } + : todo, + ), + } + : undefined; + +export const upsertPermission = ( + permissions: AgentPermissionRequest[] | undefined, + event: StreamEvent & { type: "permission_request" }, +) => { + const next = [...(permissions ?? [])]; + const index = next.findIndex((item) => item.requestId === event.requestId); + const nextItem: AgentPermissionRequest = { + requestId: event.requestId, + sessionId: event.sessionId, + permission: event.permission, + patterns: event.patterns, + metadata: event.metadata, + always: event.always, + tool: event.tool, + createdAt: event.createdAt, + status: "pending", + }; + if (index >= 0) { + next[index] = { + ...next[index], + ...nextItem, + status: next[index].status === "submitting" ? "submitting" : nextItem.status, + }; + } else { + next.push(nextItem); + } + return next; +}; + +export const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => { + if (reply === "always") return "approved_always"; + if (reply === "once") return "approved_once"; + return "rejected"; +}; + +export const isActionableQuestionRequest = (question: { + requestId: string; + tool?: AgentQuestionRequest["tool"]; +}) => Boolean(question.requestId && question.requestId !== question.tool?.callID); + +export const toQuestionRequest = ( + event: StreamEvent & { type: "question_request" }, + status: AgentQuestionRequest["status"] = "pending", +): AgentQuestionRequest => ({ + requestId: event.requestId, + sessionId: event.sessionId, + questions: event.questions, + tool: event.tool, + createdAt: event.createdAt, + status, +}); + +export const getQuestionContentSignature = ( + questions: AgentQuestionRequest["questions"], +) => + JSON.stringify( + questions.map((question) => ({ + header: question.header, + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + multiple: question.multiple ?? false, + custom: question.custom !== false, + })), + ); + +export const isSameQuestionRequest = ( + question: AgentQuestionRequest, + event: StreamEvent & { type: "question_request" }, +) => { + if (question.requestId === event.requestId) return true; + if (question.tool?.callID && event.tool?.callID) { + return question.tool.callID === event.tool.callID; + } + return ( + question.status === "pending" && + question.sessionId === event.sessionId && + getQuestionContentSignature(question.questions) === + getQuestionContentSignature(event.questions) + ); +}; + +export const isSameQuestionPair = ( + left: AgentQuestionRequest, + right: AgentQuestionRequest, +) => { + if (left.requestId === right.requestId) return true; + if (left.tool?.callID && right.tool?.callID) { + return left.tool.callID === right.tool.callID; + } + return ( + left.status === "pending" && + right.status === "pending" && + left.sessionId === right.sessionId && + getQuestionContentSignature(left.questions) === + getQuestionContentSignature(right.questions) + ); +}; + +export const dedupeQuestionsAcrossMessages = (messages: Message[]) => { + const seen: AgentQuestionRequest[] = []; + let changed = false; + const nextMessages = messages.map((message) => { + if (!message.questions?.length) { + return message; + } + const nextQuestions = message.questions.filter((question) => { + if (seen.some((existing) => isSameQuestionPair(existing, question))) { + changed = true; + return false; + } + seen.push(question); + return true; + }); + if (nextQuestions.length === message.questions.length) { + return message; + } + return { + ...message, + questions: nextQuestions.length ? nextQuestions : undefined, + }; + }); + return changed ? nextMessages : messages; +}; + +export const upsertQuestionAcrossMessages = ( + messages: Message[], + event: StreamEvent & { type: "question_request" }, + assistantMessageId: string, +) => { + let existing: AgentQuestionRequest | undefined; + for (const message of messages) { + const match = message.questions?.find((question) => + isSameQuestionRequest(question, event), + ); + if (match) { + existing = match; + break; + } + } + + const existingStatus: AgentQuestionRequest["status"] | undefined = + existing?.status === "submitting" ? "submitting" : undefined; + const nextQuestion = + existing && + isActionableQuestionRequest(existing) && + !isActionableQuestionRequest(event) + ? { + ...existing, + sessionId: event.sessionId, + questions: event.questions, + tool: event.tool ?? existing.tool, + createdAt: event.createdAt, + status: existingStatus ?? existing.status, + } + : toQuestionRequest(event, existingStatus ?? "pending"); + const targetMessageId = existing + ? messages.find((message) => + message.questions?.some((question) => isSameQuestionRequest(question, event)), + )?.id ?? assistantMessageId + : assistantMessageId; + + return messages.map((message) => { + const filteredQuestions = message.questions?.filter( + (question) => !isSameQuestionRequest(question, event), + ); + if (message.id !== targetMessageId) { + return filteredQuestions?.length === message.questions?.length + ? message + : { + ...message, + questions: filteredQuestions?.length ? filteredQuestions : undefined, + }; + } + + const nextQuestions = [...(filteredQuestions ?? []), nextQuestion]; + return { + ...message, + questions: nextQuestions, + }; + }); +}; + +export const applyQuestionResponse = ( + questions: AgentQuestionRequest[] | undefined, + event: StreamEvent & { type: "question_response" }, +) => + (questions ?? []).map((question) => + question.requestId === event.requestId + ? { + ...question, + status: event.rejected ? "rejected" as const : "answered" as const, + answers: event.answers ?? question.answers, + repliedAt: Date.now(), + error: undefined, + } + : question, + ); + +export const createTodoUpdateFromEvent = ( + event: StreamEvent & { type: "todo_update" }, +): AgentTodoUpdate => ({ + sessionId: event.sessionId, + messageId: event.messageId, + todos: event.todos, + createdAt: event.createdAt, +}); + +export const normalizeSessionTodos = ( + messages: Message[], + nextTodoUpdate?: AgentTodoUpdate, + targetAssistantMessageId?: string, +) => { + let latestTodoUpdate = nextTodoUpdate; + if (!latestTodoUpdate) { + for (const message of messages) { + if (message.todos) { + latestTodoUpdate = message.todos; + } + } + } + + if (!latestTodoUpdate) { + return messages; + } + + const targetMessageId = + targetAssistantMessageId ?? + [...messages].reverse().find((message) => message.role === "assistant")?.id; + if (!targetMessageId) { + return messages; + } + + let changed = false; + const nextMessages = messages.map((message) => { + if (message.id === targetMessageId) { + if (message.todos === latestTodoUpdate) { + return message; + } + changed = true; + return { + ...message, + todos: latestTodoUpdate, + }; + } + if (!message.todos) { + return message; + } + changed = true; + return { + ...message, + todos: undefined, + }; + }); + + return changed ? nextMessages : messages; +}; + +export const rejectOpenPermissionsAfterAbort = ( + permissions: AgentPermissionRequest[] | undefined, +) => { + if (!permissions?.length) return permissions; + let changed = false; + const nextPermissions = permissions.map((permission) => { + if ( + permission.status !== "pending" && + permission.status !== "submitting" && + permission.status !== "error" + ) { + return permission; + } + changed = true; + return { + ...permission, + status: "rejected" as const, + repliedAt: Date.now(), + error: undefined, + }; + }); + return changed ? nextPermissions : permissions; +}; + +export const rejectOpenQuestionsAfterAbort = ( + questions: AgentQuestionRequest[] | undefined, +) => { + if (!questions?.length) return questions; + let changed = false; + const nextQuestions = questions.map((question) => { + if ( + question.status !== "pending" && + question.status !== "submitting" && + question.status !== "error" + ) { + return question; + } + changed = true; + return { + ...question, + status: "rejected" as const, + repliedAt: Date.now(), + error: undefined, + }; + }); + return changed ? nextQuestions : questions; +}; + +export const finalizeAssistantMessageAfterAbort = (message: Message): Message => { + const completedProgress = completeRunningProgress(message.progress); + const cancelledTodos = cancelRunningTodos(message.todos); + const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions); + const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions); + const hasVisibleOutput = + message.content.trim().length > 0 || + Boolean(message.artifacts?.length) || + Boolean(rejectedPermissions?.length) || + Boolean(rejectedQuestions?.length) || + Boolean(completedProgress?.length) || + Boolean(cancelledTodos); + + if (!hasVisibleOutput) { + return message; + } + + return { + ...message, + content: message.content || "⚠️ **请求已中断**", + isError: true, + progress: completedProgress, + permissions: rejectedPermissions, + questions: rejectedQuestions, + todos: cancelledTodos, + }; +}; + +export const createUserMessage = (content: string): Message => { + const id = createId(); + return { + id, + role: "user", + content, + }; +}; + +export const createAssistantMessage = (): Message => ({ + id: createId(), + role: "assistant", + content: "", +}); + diff --git a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx new file mode 100644 index 0000000..3403ba7 --- /dev/null +++ b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx @@ -0,0 +1,479 @@ +"use client"; + +import { act, renderHook, waitFor } from "@testing-library/react"; + +import { useAgentChatSession } from "./useAgentChatSession"; +import { + abortAgentChat, + forkAgentChat, + replyAgentPermission, + replyAgentQuestion, + resumeAgentChatStream, + streamAgentChat, +} from "@/lib/chatStream"; +import type { StreamEvent } from "@/lib/chatStream"; + +jest.mock("@/lib/chatStream", () => ({ + abortAgentChat: jest.fn(async () => undefined), + forkAgentChat: jest.fn(async () => "forked-session"), + replyAgentPermission: jest.fn(async () => undefined), + replyAgentQuestion: jest.fn(async () => undefined), + resumeAgentChatStream: jest.fn(async () => undefined), + streamAgentChat: jest.fn(async () => undefined), +})); + +const listChatSessions = jest.fn(); +const deleteChatSession = jest.fn(); +const saveActiveChatState = jest.fn(); +const updateChatSessionTitle = jest.fn(); + +jest.mock("../chatStorage", () => ({ + createEmptyChatState: jest.fn(() => ({ + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + })), + deleteChatSession: (...args: unknown[]) => deleteChatSession(...args), + listChatSessions: (...args: unknown[]) => listChatSessions(...args), + loadChatSessionById: jest.fn(async () => ({ + title: "已存在会话", + isTitleManuallyEdited: false, + messages: [], + sessionId: "session-loaded", + })), + saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), + updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), +})); + +describe("useAgentChatSession", () => { + beforeEach(() => { + listChatSessions.mockReset(); + deleteChatSession.mockReset(); + saveActiveChatState.mockReset(); + updateChatSessionTitle.mockReset(); + jest.mocked(abortAgentChat).mockReset(); + jest.mocked(forkAgentChat).mockReset(); + jest.mocked(replyAgentPermission).mockReset(); + jest.mocked(replyAgentQuestion).mockReset(); + jest.mocked(resumeAgentChatStream).mockReset(); + jest.mocked(streamAgentChat).mockReset(); + jest.mocked(abortAgentChat).mockImplementation(async () => undefined); + jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); + jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); + jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); + jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); + jest.mocked(streamAgentChat).mockImplementation(async () => undefined); + deleteChatSession.mockImplementation(async () => undefined); + saveActiveChatState.mockImplementation(async (state) => state.sessionId); + updateChatSessionTitle.mockImplementation(async () => undefined); + }); + +describe("useAgentChatSession actions", () => { + it("tracks permission requests and submits replies", async () => { + listChatSessions.mockResolvedValue([]); + let emitStreamEvent: ((event: StreamEvent) => void) | undefined; + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + emitStreamEvent = onEvent; + await new Promise(() => undefined); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + void result.current.sendPrompt("删除临时文件"); + await Promise.resolve(); + }); + + act(() => { + emitStreamEvent?.({ + type: "permission_request", + sessionId: "session-1", + requestId: "perm-1", + permission: "bash", + patterns: ["rm *"], + metadata: { command: "rm tmp.txt" }, + always: ["rm *"], + createdAt: 123, + }); + }); + + expect(result.current.messages.at(-1)?.permissions).toEqual([ + expect.objectContaining({ + requestId: "perm-1", + sessionId: "session-1", + status: "pending", + }), + ]); + + await act(async () => { + await result.current.replyPermission("perm-1", "once"); + }); + + expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once"); + expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual( + expect.objectContaining({ + requestId: "perm-1", + status: "approved_once", + }), + ); + }); + + it("finalizes running progress when aborting an active prompt", async () => { + listChatSessions.mockResolvedValue([]); + jest.mocked(streamAgentChat).mockImplementationOnce( + ({ onEvent, signal }) => + new Promise((_, reject) => { + onEvent({ + type: "progress", + sessionId: "session-1", + id: "request-received", + phase: "start", + status: "running", + title: "开始分析", + startedAt: 1000, + } satisfies StreamEvent); + onEvent({ + type: "todo_update", + sessionId: "session-1", + todos: [ + { + id: "todo-1", + content: "分析水位", + status: "in_progress", + }, + { + id: "todo-2", + content: "生成建议", + status: "pending", + }, + ], + createdAt: 1001, + } satisfies StreamEvent); + onEvent({ + type: "permission_request", + sessionId: "session-1", + requestId: "perm-abort", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test" }, + always: ["npm test"], + createdAt: 1002, + } satisfies StreamEvent); + onEvent({ + type: "question_request", + sessionId: "session-1", + requestId: "question-abort", + questions: [ + { + header: "范围", + question: "请选择范围", + options: [{ label: "城区", description: "中心城区" }], + }, + ], + createdAt: 1003, + } satisfies StreamEvent); + + signal?.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + }), + ); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.sendPrompt("测试中断"); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + + act(() => { + result.current.abort(); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(result.current.messages.at(-1)).toEqual( + expect.objectContaining({ + role: "assistant", + content: "⚠️ **请求已中断**", + isError: true, + progress: [ + expect.objectContaining({ + id: "request-received", + status: "completed", + durationMs: expect.any(Number), + endedAt: expect.any(Number), + }), + ], + todos: expect.objectContaining({ + todos: [ + expect.objectContaining({ + id: "todo-1", + status: "cancelled", + updatedAt: expect.any(Number), + }), + expect.objectContaining({ + id: "todo-2", + status: "cancelled", + updatedAt: expect.any(Number), + }), + ], + }), + permissions: [ + expect.objectContaining({ + requestId: "perm-abort", + status: "rejected", + repliedAt: expect.any(Number), + error: undefined, + }), + ], + questions: [ + expect.objectContaining({ + requestId: "question-abort", + status: "rejected", + repliedAt: expect.any(Number), + error: undefined, + }), + ], + }), + ); + expect(abortAgentChat).toHaveBeenCalledWith("session-1"); + }); + + it("ignores generated session titles after the title was edited manually", async () => { + listChatSessions.mockResolvedValue([]); + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "session_title", + sessionId: "session-1", + title: "自动标题", + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.switchSession("session-loaded"); + }); + + await act(async () => { + await result.current.renameSession("session-loaded", "手动标题"); + }); + + await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled()); + + await act(async () => { + await result.current.sendPrompt("帮我分析一下"); + }); + + expect(result.current.sessionTitle).toBe("手动标题"); + expect(updateChatSessionTitle).not.toHaveBeenCalledWith( + "session-loaded", + "自动标题", + expect.anything(), + ); + }); + + it("does not apply a late generated title to a newly created session", async () => { + listChatSessions.mockResolvedValue([]); + let emitStreamEvent: ((event: StreamEvent) => void) | undefined; + let resolveStream: (() => void) | undefined; + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + emitStreamEvent = onEvent; + await new Promise((resolve) => { + resolveStream = resolve; + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + void result.current.sendPrompt("帮我分析一下"); + await Promise.resolve(); + }); + + act(() => { + emitStreamEvent?.({ + type: "done", + sessionId: "old-session", + }); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + act(() => { + result.current.createSession(); + }); + + expect(result.current.sessionTitle).toBe("新对话"); + + await act(async () => { + emitStreamEvent?.({ + type: "session_title", + sessionId: "old-session", + title: "旧请求标题", + }); + resolveStream?.(); + await Promise.resolve(); + }); + + expect(result.current.sessionTitle).toBe("新对话"); + expect(updateChatSessionTitle).toHaveBeenCalledWith( + "old-session", + "旧请求标题", + { isTitleManuallyEdited: false }, + ); + }); + + it("asks the backend to undo the previous user turn before regenerating", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("重新分析压力异常"); + }); + const assistantMessageId = result.current.messages[1]?.id ?? ""; + + await act(async () => { + await result.current.regenerate(assistantMessageId); + }); + + expect(streamAgentChat).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + message: "重新分析压力异常", + regenerateFromMessageIndex: 0, + }), + ); + }); + + it("replaces the current chain when regenerating a middle assistant message", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("第一轮"); + }); + + await act(async () => { + await result.current.sendPrompt("第二轮"); + }); + + const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; + + await act(async () => { + await result.current.regenerate(firstAssistantMessageId); + }); + + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toEqual( + expect.objectContaining({ + role: "user", + content: "第一轮", + }), + ); + expect(result.current.messages[1]).toEqual( + expect.objectContaining({ + role: "assistant", + content: "", + }), + ); + expect(streamAgentChat).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + message: "第一轮", + regenerateFromMessageIndex: 0, + }), + ); + }); + + it("forks a copied conversation from an assistant message", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("第一轮"); + }); + + const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; + + await act(async () => { + await result.current.createBranch(firstAssistantMessageId); + }); + + expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2); + expect(result.current.activeSessionId).toBe("forked-session"); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toEqual( + expect.objectContaining({ + role: "user", + content: "第一轮", + }), + ); + expect(result.current.messages[1]).toEqual( + expect.objectContaining({ + role: "assistant", + }), + ); + expect(streamAgentChat).toHaveBeenCalledTimes(1); + }); +}); +}); diff --git a/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx new file mode 100644 index 0000000..4601ec0 --- /dev/null +++ b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx @@ -0,0 +1,791 @@ +"use client"; + +import { act, renderHook, waitFor } from "@testing-library/react"; + +import { useAgentChatSession } from "./useAgentChatSession"; +import { + abortAgentChat, + forkAgentChat, + replyAgentPermission, + replyAgentQuestion, + resumeAgentChatStream, + streamAgentChat, +} from "@/lib/chatStream"; +import type { StreamEvent } from "@/lib/chatStream"; + +jest.mock("@/lib/chatStream", () => ({ + abortAgentChat: jest.fn(async () => undefined), + forkAgentChat: jest.fn(async () => "forked-session"), + replyAgentPermission: jest.fn(async () => undefined), + replyAgentQuestion: jest.fn(async () => undefined), + resumeAgentChatStream: jest.fn(async () => undefined), + streamAgentChat: jest.fn(async () => undefined), +})); + +const listChatSessions = jest.fn(); +const deleteChatSession = jest.fn(); +const saveActiveChatState = jest.fn(); +const updateChatSessionTitle = jest.fn(); + +jest.mock("../chatStorage", () => ({ + createEmptyChatState: jest.fn(() => ({ + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + })), + deleteChatSession: (...args: unknown[]) => deleteChatSession(...args), + listChatSessions: (...args: unknown[]) => listChatSessions(...args), + loadChatSessionById: jest.fn(async () => ({ + title: "已存在会话", + isTitleManuallyEdited: false, + messages: [], + sessionId: "session-loaded", + })), + saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), + updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), +})); + +describe("useAgentChatSession", () => { + beforeEach(() => { + listChatSessions.mockReset(); + deleteChatSession.mockReset(); + saveActiveChatState.mockReset(); + updateChatSessionTitle.mockReset(); + jest.mocked(abortAgentChat).mockReset(); + jest.mocked(forkAgentChat).mockReset(); + jest.mocked(replyAgentPermission).mockReset(); + jest.mocked(replyAgentQuestion).mockReset(); + jest.mocked(resumeAgentChatStream).mockReset(); + jest.mocked(streamAgentChat).mockReset(); + jest.mocked(abortAgentChat).mockImplementation(async () => undefined); + jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); + jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); + jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); + jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); + jest.mocked(streamAgentChat).mockImplementation(async () => undefined); + deleteChatSession.mockImplementation(async () => undefined); + saveActiveChatState.mockImplementation(async (state) => state.sessionId); + updateChatSessionTitle.mockImplementation(async () => undefined); + }); + +describe("useAgentChatSession lifecycle and resume", () => { + it("does not add a new empty session to history until there is actual chat content", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.createSession(); + }); + + await waitFor(() => expect(result.current.sessionTitle).toBe("新对话")); + expect(result.current.chatSessions).toEqual([]); + expect(result.current.activeSessionId).toBeUndefined(); + expect(result.current.messages).toEqual([]); + expect(result.current.isStreaming).toBe(false); + expect(listChatSessions).toHaveBeenCalledTimes(1); + }); + + it("keeps existing history entries when creating a blank new session", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-1", + title: "已有会话", + createdAt: 1, + updatedAt: 1, + }, + ]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.createSession(); + }); + + expect(result.current.chatSessions).toEqual([ + { + id: "session-1", + title: "已有会话", + createdAt: 1, + updatedAt: 1, + }, + ]); + }); + + it("removes a deleted history entry before the backend delete finishes", async () => { + const initialSessions = [ + { + id: "session-1", + title: "第一段会话", + createdAt: 2, + updatedAt: 2, + }, + { + id: "session-2", + title: "第二段会话", + createdAt: 1, + updatedAt: 1, + }, + ]; + let resolveDelete: ((nextActiveSessionId?: string) => void) | undefined; + + listChatSessions.mockResolvedValue(initialSessions); + deleteChatSession.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveDelete = resolve; + }), + ); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.removeSession("session-2"); + }); + + expect(result.current.chatSessions).toEqual([ + expect.objectContaining({ id: "session-1" }), + ]); + + listChatSessions.mockResolvedValue([ + { + id: "session-1", + title: "第一段会话", + createdAt: 2, + updatedAt: 2, + }, + ]); + + await act(async () => { + resolveDelete?.(); + await Promise.resolve(); + }); + + await waitFor(() => + expect(result.current.chatSessions).toEqual([ + expect.objectContaining({ id: "session-1" }), + ]), + ); + }); + + it("persists a new conversation only after the stream is done", async () => { + listChatSessions.mockResolvedValue([]); + let emitStreamEvent: ((event: StreamEvent) => void) | undefined; + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + emitStreamEvent = onEvent; + await new Promise(() => undefined); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + jest.useFakeTimers(); + try { + await act(async () => { + void result.current.sendPrompt("第一条消息"); + await Promise.resolve(); + }); + + expect(result.current.isStreaming).toBe(true); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + expect(saveActiveChatState).not.toHaveBeenCalled(); + + act(() => { + emitStreamEvent?.({ + type: "token", + sessionId: "chat-stream-1", + content: "收到", + }); + }); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + expect(saveActiveChatState).not.toHaveBeenCalled(); + + act(() => { + emitStreamEvent?.({ + type: "done", + sessionId: "chat-stream-1", + }); + }); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1)); + expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({ + sessionId: "chat-stream-1", + messages: [ + expect.objectContaining({ role: "user", content: "第一条消息" }), + expect.objectContaining({ role: "assistant", content: "收到" }), + ], + }); + } finally { + jest.useRealTimers(); + } + }); + + it("shows shared todo state only on the latest assistant message in a session", async () => { + listChatSessions.mockResolvedValue([]); + jest.mocked(streamAgentChat) + .mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "todo_update", + sessionId: "session-1", + todos: [ + { + id: "todo-1", + content: "创建任务列表", + status: "in_progress", + }, + ], + createdAt: 1000, + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }) + .mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "todo_update", + sessionId: "session-1", + todos: [ + { + id: "todo-1", + content: "创建任务列表", + status: "completed", + }, + { + id: "todo-2", + content: "更新任务状态", + status: "in_progress", + }, + ], + createdAt: 2000, + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("创建任务"); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("更新任务"); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + const assistantMessages = result.current.messages.filter( + (message) => message.role === "assistant", + ); + + expect(assistantMessages).toHaveLength(2); + expect(assistantMessages[0].todos).toBeUndefined(); + expect(assistantMessages[1].todos).toEqual( + expect.objectContaining({ + sessionId: "session-1", + createdAt: 2000, + todos: [ + expect.objectContaining({ + id: "todo-1", + status: "completed", + }), + expect.objectContaining({ + id: "todo-2", + status: "in_progress", + }), + ], + }), + ); + }); + + it("hydrates a backend streaming session and resumes its stream", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + runStatus: "running", + }, + ]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + expect(result.current.isStreaming).toBe(true); + expect(result.current.activeSessionId).toBe("session-loaded"); + expect(resumeAgentChatStream).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-loaded", + }), + ); + }); + + it("updates resumed messages from state, token, and done events", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { id: "a1", role: "assistant", content: "已有" }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "token", + sessionId: "session-loaded", + content: "输出", + }); + onEvent({ + type: "done", + sessionId: "session-loaded", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(result.current.messages).toEqual([ + expect.objectContaining({ id: "u1", role: "user", content: "继续分析" }), + expect.objectContaining({ id: "a1", role: "assistant", content: "已有输出" }), + ]); + }); + + it("applies question responses to the message that owns the request", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [ + { + requestId: "q-1", + sessionId: "session-loaded", + questions: [ + { + header: "范围", + question: "选择范围", + options: [], + custom: true, + }, + ], + createdAt: 123, + status: "pending", + }, + ], + }, + { id: "a2", role: "assistant", content: "后续消息" }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "question_response", + sessionId: "session-loaded", + requestId: "q-1", + answers: [["城区"]], + rejected: false, + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + expect(result.current.messages[1].questions?.[0]).toEqual( + expect.objectContaining({ + requestId: "q-1", + status: "answered", + answers: [["城区"]], + }), + ); + expect(result.current.messages[2].questions).toBeUndefined(); + }); + + it("deduplicates question requests across assistant messages", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [ + { + requestId: "question-1", + sessionId: "session-loaded", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 123, + status: "pending", + }, + ], + }, + { id: "a2", role: "assistant", content: "后续消息" }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "question_request", + sessionId: "session-loaded", + requestId: "call-1", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 456, + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + const allQuestions = result.current.messages.flatMap( + (message) => message.questions ?? [], + ); + expect(allQuestions).toHaveLength(1); + expect(result.current.messages[1].questions?.[0]).toEqual( + expect.objectContaining({ + requestId: "question-1", + tool: expect.objectContaining({ callID: "call-1" }), + }), + ); + expect(result.current.messages[2].questions).toBeUndefined(); + }); + + it("keeps the actionable question request id when a tool-part duplicate arrives later", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [ + { + requestId: "question-1", + sessionId: "session-loaded", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 123, + status: "pending", + }, + ], + }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "question_request", + sessionId: "session-loaded", + requestId: "call-1", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 456, + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + const allQuestions = result.current.messages.flatMap( + (message) => message.questions ?? [], + ); + expect(allQuestions).toHaveLength(1); + expect(allQuestions[0]).toEqual( + expect.objectContaining({ + requestId: "question-1", + tool: expect.objectContaining({ callID: "call-1" }), + }), + ); + }); + + it("deduplicates persisted duplicate questions from state events", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + const duplicateQuestion = { + sessionId: "session-loaded", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 123, + status: "pending" as const, + }; + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [{ ...duplicateQuestion, requestId: "question-1" }], + }, + { + id: "a2", + role: "assistant", + content: "后续消息", + questions: [{ ...duplicateQuestion, requestId: "call-1" }], + }, + ], + isStreaming: true, + runStatus: "running", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + expect( + result.current.messages.flatMap((message) => message.questions ?? []), + ).toHaveLength(1); + expect(result.current.messages[1].questions).toHaveLength(1); + expect(result.current.messages[2].questions).toBeUndefined(); + }); + + it("aborts a resumed streaming session through the backend abort endpoint", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async () => { + await new Promise(() => undefined); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + + act(() => { + result.current.abort(); + }); + + expect(abortAgentChat).toHaveBeenCalledWith("session-loaded"); + }); + +}); +}); diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index edc75cf..55c7215 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -1,1194 +1,2 @@ -"use client"; - -import { act, renderHook, waitFor } from "@testing-library/react"; - -import { useAgentChatSession } from "./useAgentChatSession"; -import { - abortAgentChat, - forkAgentChat, - replyAgentPermission, - replyAgentQuestion, - resumeAgentChatStream, - streamAgentChat, -} from "@/lib/chatStream"; -import type { StreamEvent } from "@/lib/chatStream"; - -jest.mock("@/lib/chatStream", () => ({ - abortAgentChat: jest.fn(async () => undefined), - forkAgentChat: jest.fn(async () => "forked-session"), - replyAgentPermission: jest.fn(async () => undefined), - replyAgentQuestion: jest.fn(async () => undefined), - resumeAgentChatStream: jest.fn(async () => undefined), - streamAgentChat: jest.fn(async () => undefined), -})); - -const listChatSessions = jest.fn(); -const deleteChatSession = jest.fn(); -const saveActiveChatState = jest.fn(); -const updateChatSessionTitle = jest.fn(); - -jest.mock("../chatStorage", () => ({ - createEmptyChatState: jest.fn(() => ({ - title: undefined, - isTitleManuallyEdited: false, - messages: [], - sessionId: undefined, - })), - deleteChatSession: (...args: unknown[]) => deleteChatSession(...args), - listChatSessions: (...args: unknown[]) => listChatSessions(...args), - loadChatSessionById: jest.fn(async () => ({ - title: "已存在会话", - isTitleManuallyEdited: false, - messages: [], - sessionId: "session-loaded", - })), - saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), - updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), -})); - -describe("useAgentChatSession", () => { - beforeEach(() => { - listChatSessions.mockReset(); - deleteChatSession.mockReset(); - saveActiveChatState.mockReset(); - updateChatSessionTitle.mockReset(); - jest.mocked(abortAgentChat).mockReset(); - jest.mocked(forkAgentChat).mockReset(); - jest.mocked(replyAgentPermission).mockReset(); - jest.mocked(replyAgentQuestion).mockReset(); - jest.mocked(resumeAgentChatStream).mockReset(); - jest.mocked(streamAgentChat).mockReset(); - jest.mocked(abortAgentChat).mockImplementation(async () => undefined); - jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); - jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); - jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); - jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); - jest.mocked(streamAgentChat).mockImplementation(async () => undefined); - deleteChatSession.mockImplementation(async () => undefined); - saveActiveChatState.mockImplementation(async (state) => state.sessionId); - updateChatSessionTitle.mockImplementation(async () => undefined); - }); - - it("does not add a new empty session to history until there is actual chat content", async () => { - listChatSessions.mockResolvedValue([]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - act(() => { - void result.current.createSession(); - }); - - await waitFor(() => expect(result.current.sessionTitle).toBe("新对话")); - expect(result.current.chatSessions).toEqual([]); - expect(result.current.activeSessionId).toBeUndefined(); - expect(result.current.messages).toEqual([]); - expect(result.current.isStreaming).toBe(false); - expect(listChatSessions).toHaveBeenCalledTimes(1); - }); - - it("keeps existing history entries when creating a blank new session", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-1", - title: "已有会话", - createdAt: 1, - updatedAt: 1, - }, - ]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - act(() => { - void result.current.createSession(); - }); - - expect(result.current.chatSessions).toEqual([ - { - id: "session-1", - title: "已有会话", - createdAt: 1, - updatedAt: 1, - }, - ]); - }); - - it("removes a deleted history entry before the backend delete finishes", async () => { - const initialSessions = [ - { - id: "session-1", - title: "第一段会话", - createdAt: 2, - updatedAt: 2, - }, - { - id: "session-2", - title: "第二段会话", - createdAt: 1, - updatedAt: 1, - }, - ]; - let resolveDelete: ((nextActiveSessionId?: string) => void) | undefined; - - listChatSessions.mockResolvedValue(initialSessions); - deleteChatSession.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveDelete = resolve; - }), - ); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - act(() => { - void result.current.removeSession("session-2"); - }); - - expect(result.current.chatSessions).toEqual([ - expect.objectContaining({ id: "session-1" }), - ]); - - listChatSessions.mockResolvedValue([ - { - id: "session-1", - title: "第一段会话", - createdAt: 2, - updatedAt: 2, - }, - ]); - - await act(async () => { - resolveDelete?.(); - await Promise.resolve(); - }); - - await waitFor(() => - expect(result.current.chatSessions).toEqual([ - expect.objectContaining({ id: "session-1" }), - ]), - ); - }); - - it("persists a new conversation only after the stream is done", async () => { - listChatSessions.mockResolvedValue([]); - let emitStreamEvent: ((event: StreamEvent) => void) | undefined; - jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { - emitStreamEvent = onEvent; - await new Promise(() => undefined); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - jest.useFakeTimers(); - try { - await act(async () => { - void result.current.sendPrompt("第一条消息"); - await Promise.resolve(); - }); - - expect(result.current.isStreaming).toBe(true); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - expect(saveActiveChatState).not.toHaveBeenCalled(); - - act(() => { - emitStreamEvent?.({ - type: "token", - sessionId: "chat-stream-1", - content: "收到", - }); - }); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - expect(saveActiveChatState).not.toHaveBeenCalled(); - - act(() => { - emitStreamEvent?.({ - type: "done", - sessionId: "chat-stream-1", - }); - }); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1)); - expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({ - sessionId: "chat-stream-1", - messages: [ - expect.objectContaining({ role: "user", content: "第一条消息" }), - expect.objectContaining({ role: "assistant", content: "收到" }), - ], - }); - } finally { - jest.useRealTimers(); - } - }); - - it("shows shared todo state only on the latest assistant message in a session", async () => { - listChatSessions.mockResolvedValue([]); - jest.mocked(streamAgentChat) - .mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "todo_update", - sessionId: "session-1", - todos: [ - { - id: "todo-1", - content: "创建任务列表", - status: "in_progress", - }, - ], - createdAt: 1000, - }); - onEvent({ - type: "done", - sessionId: "session-1", - }); - }) - .mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "todo_update", - sessionId: "session-1", - todos: [ - { - id: "todo-1", - content: "创建任务列表", - status: "completed", - }, - { - id: "todo-2", - content: "更新任务状态", - status: "in_progress", - }, - ], - createdAt: 2000, - }); - onEvent({ - type: "done", - sessionId: "session-1", - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("创建任务"); - }); - await waitFor(() => expect(result.current.isStreaming).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("更新任务"); - }); - await waitFor(() => expect(result.current.isStreaming).toBe(false)); - - const assistantMessages = result.current.messages.filter( - (message) => message.role === "assistant", - ); - - expect(assistantMessages).toHaveLength(2); - expect(assistantMessages[0].todos).toBeUndefined(); - expect(assistantMessages[1].todos).toEqual( - expect.objectContaining({ - sessionId: "session-1", - createdAt: 2000, - todos: [ - expect.objectContaining({ - id: "todo-1", - status: "completed", - }), - expect.objectContaining({ - id: "todo-2", - status: "in_progress", - }), - ], - }), - ); - }); - - it("hydrates a backend streaming session and resumes its stream", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - runStatus: "running", - }, - ]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - expect(result.current.isStreaming).toBe(true); - expect(result.current.activeSessionId).toBe("session-loaded"); - expect(resumeAgentChatStream).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: "session-loaded", - }), - ); - }); - - it("updates resumed messages from state, token, and done events", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "state", - sessionId: "session-loaded", - messages: [ - { id: "u1", role: "user", content: "继续分析" }, - { id: "a1", role: "assistant", content: "已有" }, - ], - isStreaming: true, - runStatus: "running", - }); - onEvent({ - type: "token", - sessionId: "session-loaded", - content: "输出", - }); - onEvent({ - type: "done", - sessionId: "session-loaded", - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - await waitFor(() => expect(result.current.isStreaming).toBe(false)); - - expect(result.current.messages).toEqual([ - expect.objectContaining({ id: "u1", role: "user", content: "继续分析" }), - expect.objectContaining({ id: "a1", role: "assistant", content: "已有输出" }), - ]); - }); - - it("applies question responses to the message that owns the request", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "state", - sessionId: "session-loaded", - messages: [ - { id: "u1", role: "user", content: "继续分析" }, - { - id: "a1", - role: "assistant", - content: "需要确认", - questions: [ - { - requestId: "q-1", - sessionId: "session-loaded", - questions: [ - { - header: "范围", - question: "选择范围", - options: [], - custom: true, - }, - ], - createdAt: 123, - status: "pending", - }, - ], - }, - { id: "a2", role: "assistant", content: "后续消息" }, - ], - isStreaming: true, - runStatus: "running", - }); - onEvent({ - type: "question_response", - sessionId: "session-loaded", - requestId: "q-1", - answers: [["城区"]], - rejected: false, - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - expect(result.current.messages[1].questions?.[0]).toEqual( - expect.objectContaining({ - requestId: "q-1", - status: "answered", - answers: [["城区"]], - }), - ); - expect(result.current.messages[2].questions).toBeUndefined(); - }); - - it("deduplicates question requests across assistant messages", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "state", - sessionId: "session-loaded", - messages: [ - { id: "u1", role: "user", content: "继续分析" }, - { - id: "a1", - role: "assistant", - content: "需要确认", - questions: [ - { - requestId: "question-1", - sessionId: "session-loaded", - questions: [ - { - header: "测试问题", - question: "你觉得这个 question 工具好用吗?", - options: [ - { - label: "非常好用", - description: "交互清晰,选项方便", - }, - ], - }, - ], - tool: { - messageID: "message-1", - callID: "call-1", - }, - createdAt: 123, - status: "pending", - }, - ], - }, - { id: "a2", role: "assistant", content: "后续消息" }, - ], - isStreaming: true, - runStatus: "running", - }); - onEvent({ - type: "question_request", - sessionId: "session-loaded", - requestId: "call-1", - questions: [ - { - header: "测试问题", - question: "你觉得这个 question 工具好用吗?", - options: [ - { - label: "非常好用", - description: "交互清晰,选项方便", - }, - ], - }, - ], - tool: { - messageID: "message-1", - callID: "call-1", - }, - createdAt: 456, - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - const allQuestions = result.current.messages.flatMap( - (message) => message.questions ?? [], - ); - expect(allQuestions).toHaveLength(1); - expect(result.current.messages[1].questions?.[0]).toEqual( - expect.objectContaining({ - requestId: "question-1", - tool: expect.objectContaining({ callID: "call-1" }), - }), - ); - expect(result.current.messages[2].questions).toBeUndefined(); - }); - - it("keeps the actionable question request id when a tool-part duplicate arrives later", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "state", - sessionId: "session-loaded", - messages: [ - { id: "u1", role: "user", content: "继续分析" }, - { - id: "a1", - role: "assistant", - content: "需要确认", - questions: [ - { - requestId: "question-1", - sessionId: "session-loaded", - questions: [ - { - header: "测试问题", - question: "你觉得这个 question 工具好用吗?", - options: [ - { - label: "非常好用", - description: "交互清晰,选项方便", - }, - ], - }, - ], - tool: { - messageID: "message-1", - callID: "call-1", - }, - createdAt: 123, - status: "pending", - }, - ], - }, - ], - isStreaming: true, - runStatus: "running", - }); - onEvent({ - type: "question_request", - sessionId: "session-loaded", - requestId: "call-1", - questions: [ - { - header: "测试问题", - question: "你觉得这个 question 工具好用吗?", - options: [ - { - label: "非常好用", - description: "交互清晰,选项方便", - }, - ], - }, - ], - tool: { - messageID: "message-1", - callID: "call-1", - }, - createdAt: 456, - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - const allQuestions = result.current.messages.flatMap( - (message) => message.questions ?? [], - ); - expect(allQuestions).toHaveLength(1); - expect(allQuestions[0]).toEqual( - expect.objectContaining({ - requestId: "question-1", - tool: expect.objectContaining({ callID: "call-1" }), - }), - ); - }); - - it("deduplicates persisted duplicate questions from state events", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - const duplicateQuestion = { - sessionId: "session-loaded", - questions: [ - { - header: "测试问题", - question: "你觉得这个 question 工具好用吗?", - options: [ - { - label: "非常好用", - description: "交互清晰,选项方便", - }, - ], - }, - ], - tool: { - messageID: "message-1", - callID: "call-1", - }, - createdAt: 123, - status: "pending" as const, - }; - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "state", - sessionId: "session-loaded", - messages: [ - { id: "u1", role: "user", content: "继续分析" }, - { - id: "a1", - role: "assistant", - content: "需要确认", - questions: [{ ...duplicateQuestion, requestId: "question-1" }], - }, - { - id: "a2", - role: "assistant", - content: "后续消息", - questions: [{ ...duplicateQuestion, requestId: "call-1" }], - }, - ], - isStreaming: true, - runStatus: "running", - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - expect( - result.current.messages.flatMap((message) => message.questions ?? []), - ).toHaveLength(1); - expect(result.current.messages[1].questions).toHaveLength(1); - expect(result.current.messages[2].questions).toBeUndefined(); - }); - - it("aborts a resumed streaming session through the backend abort endpoint", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async () => { - await new Promise(() => undefined); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isStreaming).toBe(true)); - - act(() => { - result.current.abort(); - }); - - expect(abortAgentChat).toHaveBeenCalledWith("session-loaded"); - }); - - it("tracks permission requests and submits replies", async () => { - listChatSessions.mockResolvedValue([]); - let emitStreamEvent: ((event: StreamEvent) => void) | undefined; - jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { - emitStreamEvent = onEvent; - await new Promise(() => undefined); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - void result.current.sendPrompt("删除临时文件"); - await Promise.resolve(); - }); - - act(() => { - emitStreamEvent?.({ - type: "permission_request", - sessionId: "session-1", - requestId: "perm-1", - permission: "bash", - patterns: ["rm *"], - metadata: { command: "rm tmp.txt" }, - always: ["rm *"], - createdAt: 123, - }); - }); - - expect(result.current.messages.at(-1)?.permissions).toEqual([ - expect.objectContaining({ - requestId: "perm-1", - sessionId: "session-1", - status: "pending", - }), - ]); - - await act(async () => { - await result.current.replyPermission("perm-1", "once"); - }); - - expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once"); - expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual( - expect.objectContaining({ - requestId: "perm-1", - status: "approved_once", - }), - ); - }); - - it("finalizes running progress when aborting an active prompt", async () => { - listChatSessions.mockResolvedValue([]); - jest.mocked(streamAgentChat).mockImplementationOnce( - ({ onEvent, signal }) => - new Promise((_, reject) => { - onEvent({ - type: "progress", - sessionId: "session-1", - id: "request-received", - phase: "start", - status: "running", - title: "开始分析", - startedAt: 1000, - } satisfies StreamEvent); - onEvent({ - type: "todo_update", - sessionId: "session-1", - todos: [ - { - id: "todo-1", - content: "分析水位", - status: "in_progress", - }, - { - id: "todo-2", - content: "生成建议", - status: "pending", - }, - ], - createdAt: 1001, - } satisfies StreamEvent); - onEvent({ - type: "permission_request", - sessionId: "session-1", - requestId: "perm-abort", - permission: "bash", - patterns: ["npm test"], - metadata: { command: "npm test" }, - always: ["npm test"], - createdAt: 1002, - } satisfies StreamEvent); - onEvent({ - type: "question_request", - sessionId: "session-1", - requestId: "question-abort", - questions: [ - { - header: "范围", - question: "请选择范围", - options: [{ label: "城区", description: "中心城区" }], - }, - ], - createdAt: 1003, - } satisfies StreamEvent); - - signal?.addEventListener("abort", () => { - reject(new Error("aborted")); - }); - }), - ); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - act(() => { - void result.current.sendPrompt("测试中断"); - }); - - await waitFor(() => expect(result.current.isStreaming).toBe(true)); - - act(() => { - result.current.abort(); - }); - - await waitFor(() => expect(result.current.isStreaming).toBe(false)); - - expect(result.current.messages.at(-1)).toEqual( - expect.objectContaining({ - role: "assistant", - content: "⚠️ **请求已中断**", - isError: true, - progress: [ - expect.objectContaining({ - id: "request-received", - status: "completed", - durationMs: expect.any(Number), - endedAt: expect.any(Number), - }), - ], - todos: expect.objectContaining({ - todos: [ - expect.objectContaining({ - id: "todo-1", - status: "cancelled", - updatedAt: expect.any(Number), - }), - expect.objectContaining({ - id: "todo-2", - status: "cancelled", - updatedAt: expect.any(Number), - }), - ], - }), - permissions: [ - expect.objectContaining({ - requestId: "perm-abort", - status: "rejected", - repliedAt: expect.any(Number), - error: undefined, - }), - ], - questions: [ - expect.objectContaining({ - requestId: "question-abort", - status: "rejected", - repliedAt: expect.any(Number), - error: undefined, - }), - ], - }), - ); - expect(abortAgentChat).toHaveBeenCalledWith("session-1"); - }); - - it("ignores generated session titles after the title was edited manually", async () => { - listChatSessions.mockResolvedValue([]); - jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "session_title", - sessionId: "session-1", - title: "自动标题", - }); - onEvent({ - type: "done", - sessionId: "session-1", - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.switchSession("session-loaded"); - }); - - await act(async () => { - await result.current.renameSession("session-loaded", "手动标题"); - }); - - await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled()); - - await act(async () => { - await result.current.sendPrompt("帮我分析一下"); - }); - - expect(result.current.sessionTitle).toBe("手动标题"); - expect(updateChatSessionTitle).not.toHaveBeenCalledWith( - "session-loaded", - "自动标题", - expect.anything(), - ); - }); - - it("does not apply a late generated title to a newly created session", async () => { - listChatSessions.mockResolvedValue([]); - let emitStreamEvent: ((event: StreamEvent) => void) | undefined; - let resolveStream: (() => void) | undefined; - jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { - emitStreamEvent = onEvent; - await new Promise((resolve) => { - resolveStream = resolve; - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - void result.current.sendPrompt("帮我分析一下"); - await Promise.resolve(); - }); - - act(() => { - emitStreamEvent?.({ - type: "done", - sessionId: "old-session", - }); - }); - - await waitFor(() => expect(result.current.isStreaming).toBe(false)); - - act(() => { - result.current.createSession(); - }); - - expect(result.current.sessionTitle).toBe("新对话"); - - await act(async () => { - emitStreamEvent?.({ - type: "session_title", - sessionId: "old-session", - title: "旧请求标题", - }); - resolveStream?.(); - await Promise.resolve(); - }); - - expect(result.current.sessionTitle).toBe("新对话"); - expect(updateChatSessionTitle).toHaveBeenCalledWith( - "old-session", - "旧请求标题", - { isTitleManuallyEdited: false }, - ); - }); - - it("asks the backend to undo the previous user turn before regenerating", async () => { - listChatSessions.mockResolvedValue([]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("重新分析压力异常"); - }); - const assistantMessageId = result.current.messages[1]?.id ?? ""; - - await act(async () => { - await result.current.regenerate(assistantMessageId); - }); - - expect(streamAgentChat).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - message: "重新分析压力异常", - regenerateFromMessageIndex: 0, - }), - ); - }); - - it("replaces the current chain when regenerating a middle assistant message", async () => { - listChatSessions.mockResolvedValue([]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("第一轮"); - }); - - await act(async () => { - await result.current.sendPrompt("第二轮"); - }); - - const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; - - await act(async () => { - await result.current.regenerate(firstAssistantMessageId); - }); - - expect(result.current.messages).toHaveLength(2); - expect(result.current.messages[0]).toEqual( - expect.objectContaining({ - role: "user", - content: "第一轮", - }), - ); - expect(result.current.messages[1]).toEqual( - expect.objectContaining({ - role: "assistant", - content: "", - }), - ); - expect(streamAgentChat).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - message: "第一轮", - regenerateFromMessageIndex: 0, - }), - ); - }); - - it("forks a copied conversation from an assistant message", async () => { - listChatSessions.mockResolvedValue([]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("第一轮"); - }); - - const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; - - await act(async () => { - await result.current.createBranch(firstAssistantMessageId); - }); - - expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2); - expect(result.current.activeSessionId).toBe("forked-session"); - expect(result.current.messages).toHaveLength(2); - expect(result.current.messages[0]).toEqual( - expect.objectContaining({ - role: "user", - content: "第一轮", - }), - ); - expect(result.current.messages[1]).toEqual( - expect.objectContaining({ - role: "assistant", - }), - ); - expect(streamAgentChat).toHaveBeenCalledTimes(1); - }); -}); +// Tests for useAgentChatSession are split by behavior boundary. +// See useAgentChatSession.lifecycle.test.tsx and useAgentChatSession.actions.test.tsx. diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index bb1ace1..b5f70c3 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -2,509 +2,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { - abortAgentChat, - forkAgentChat, - rejectAgentQuestion, - replyAgentPermission, - replyAgentQuestion, - resumeAgentChatStream, - streamAgentChat, -} from "@/lib/chatStream"; -import type { - AgentApprovalMode, - AgentModel, - AgentQuestionRequest, - AgentTodoUpdate, - PermissionReply, - StreamEvent, -} from "@/lib/chatStream"; -import type { - AgentArtifact, - AgentPermissionRequest, - ChatProgress, - ChatSessionSummary, - LoadedChatState, - Message, -} from "../GlobalChatbox.types"; -import { - cloneMessages, - createId, -} from "../GlobalChatbox.utils"; -import { - createEmptyChatState, - deleteChatSession, - listChatSessions, - loadChatSessionById, - saveActiveChatState, - updateChatSessionTitle, -} from "../chatStorage"; - -type UseAgentChatSessionOptions = { - projectId?: string | null; - onToolCall: ( - event: StreamEvent & { type: "tool_call" }, - options: { - assistantMessageId: string; - appendArtifact: (messageId: string, artifact: AgentArtifact) => void; - }, - ) => void; - onBeforeSend?: () => void; - getModel?: () => AgentModel; - getApprovalMode?: () => AgentApprovalMode; -}; - -type PromptRunOptions = { - prompt: string; - sessionIdOverride?: string; - regenerateFromMessageIndex?: number; - preparedMessages?: Message[]; - userMessage?: Message; - assistantMessage?: Message; -}; - -const createPersistedStateKey = (state: LoadedChatState) => - JSON.stringify({ - title: state.title ?? null, - isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, - sessionId: state.sessionId ?? null, - messages: state.messages, - }); - -const upsertProgress = ( - progress: ChatProgress[] | undefined, - event: StreamEvent & { type: "progress" }, -) => { - const next = [...(progress ?? [])]; - const index = next.findIndex((item) => item.id === event.id); - const existing = index >= 0 ? next[index] : undefined; - const now = Date.now(); - const startedAt = event.startedAt ?? existing?.startedAt; - const isRunning = event.status === "running"; - const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now; - const elapsedMs = isRunning - ? event.elapsedMs ?? - existing?.elapsedMs ?? - (startedAt !== undefined ? Math.max(0, now - startedAt) : undefined) - : undefined; - const elapsedSnapshotAt = isRunning - ? event.elapsedMs !== undefined - ? now - : existing?.elapsedSnapshotAt ?? now - : undefined; - const durationMs = !isRunning - ? event.durationMs ?? - existing?.durationMs ?? - (startedAt !== undefined && endedAt !== undefined - ? Math.max(0, endedAt - startedAt) - : undefined) - : undefined; - const nextItem: ChatProgress = { - id: event.id, - phase: event.phase, - status: event.status, - title: event.title, - detail: event.detail, - startedAt, - endedAt, - elapsedMs, - elapsedSnapshotAt, - durationMs, - }; - if (index >= 0) { - next[index] = nextItem; - } else { - next.push(nextItem); - } - return next; -}; - -const completeRunningProgress = (progress: ChatProgress[] | undefined) => - progress?.map((item) => { - if (item.status !== "running") { - return item; - } - const endedAt = Date.now(); - return { - ...item, - status: "completed" as const, - endedAt, - elapsedMs: undefined, - elapsedSnapshotAt: undefined, - durationMs: - item.durationMs ?? - (item.startedAt !== undefined - ? Math.max(0, endedAt - item.startedAt) - : item.elapsedMs), - }; - }); - -const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) => - todoUpdate - ? { - ...todoUpdate, - todos: todoUpdate.todos.map((todo) => - todo.status === "pending" || todo.status === "in_progress" - ? { - ...todo, - status: "cancelled" as const, - updatedAt: Date.now(), - } - : todo, - ), - } - : undefined; - -const upsertPermission = ( - permissions: AgentPermissionRequest[] | undefined, - event: StreamEvent & { type: "permission_request" }, -) => { - const next = [...(permissions ?? [])]; - const index = next.findIndex((item) => item.requestId === event.requestId); - const nextItem: AgentPermissionRequest = { - requestId: event.requestId, - sessionId: event.sessionId, - permission: event.permission, - patterns: event.patterns, - metadata: event.metadata, - always: event.always, - tool: event.tool, - createdAt: event.createdAt, - status: "pending", - }; - if (index >= 0) { - next[index] = { - ...next[index], - ...nextItem, - status: next[index].status === "submitting" ? "submitting" : nextItem.status, - }; - } else { - next.push(nextItem); - } - return next; -}; - -const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => { - if (reply === "always") return "approved_always"; - if (reply === "once") return "approved_once"; - return "rejected"; -}; - -const isActionableQuestionRequest = (question: { - requestId: string; - tool?: AgentQuestionRequest["tool"]; -}) => Boolean(question.requestId && question.requestId !== question.tool?.callID); - -const toQuestionRequest = ( - event: StreamEvent & { type: "question_request" }, - status: AgentQuestionRequest["status"] = "pending", -): AgentQuestionRequest => ({ - requestId: event.requestId, - sessionId: event.sessionId, - questions: event.questions, - tool: event.tool, - createdAt: event.createdAt, - status, -}); - -const getQuestionContentSignature = ( - questions: AgentQuestionRequest["questions"], -) => - JSON.stringify( - questions.map((question) => ({ - header: question.header, - question: question.question, - options: question.options.map((option) => ({ - label: option.label, - description: option.description, - })), - multiple: question.multiple ?? false, - custom: question.custom !== false, - })), - ); - -const isSameQuestionRequest = ( - question: AgentQuestionRequest, - event: StreamEvent & { type: "question_request" }, -) => { - if (question.requestId === event.requestId) return true; - if (question.tool?.callID && event.tool?.callID) { - return question.tool.callID === event.tool.callID; - } - return ( - question.status === "pending" && - question.sessionId === event.sessionId && - getQuestionContentSignature(question.questions) === - getQuestionContentSignature(event.questions) - ); -}; - -const isSameQuestionPair = ( - left: AgentQuestionRequest, - right: AgentQuestionRequest, -) => { - if (left.requestId === right.requestId) return true; - if (left.tool?.callID && right.tool?.callID) { - return left.tool.callID === right.tool.callID; - } - return ( - left.status === "pending" && - right.status === "pending" && - left.sessionId === right.sessionId && - getQuestionContentSignature(left.questions) === - getQuestionContentSignature(right.questions) - ); -}; - -const dedupeQuestionsAcrossMessages = (messages: Message[]) => { - const seen: AgentQuestionRequest[] = []; - let changed = false; - const nextMessages = messages.map((message) => { - if (!message.questions?.length) { - return message; - } - const nextQuestions = message.questions.filter((question) => { - if (seen.some((existing) => isSameQuestionPair(existing, question))) { - changed = true; - return false; - } - seen.push(question); - return true; - }); - if (nextQuestions.length === message.questions.length) { - return message; - } - return { - ...message, - questions: nextQuestions.length ? nextQuestions : undefined, - }; - }); - return changed ? nextMessages : messages; -}; - -const upsertQuestionAcrossMessages = ( - messages: Message[], - event: StreamEvent & { type: "question_request" }, - assistantMessageId: string, -) => { - let existing: AgentQuestionRequest | undefined; - for (const message of messages) { - const match = message.questions?.find((question) => - isSameQuestionRequest(question, event), - ); - if (match) { - existing = match; - break; - } - } - - const existingStatus: AgentQuestionRequest["status"] | undefined = - existing?.status === "submitting" ? "submitting" : undefined; - const nextQuestion = - existing && - isActionableQuestionRequest(existing) && - !isActionableQuestionRequest(event) - ? { - ...existing, - sessionId: event.sessionId, - questions: event.questions, - tool: event.tool ?? existing.tool, - createdAt: event.createdAt, - status: existingStatus ?? existing.status, - } - : toQuestionRequest(event, existingStatus ?? "pending"); - const targetMessageId = existing - ? messages.find((message) => - message.questions?.some((question) => isSameQuestionRequest(question, event)), - )?.id ?? assistantMessageId - : assistantMessageId; - - return messages.map((message) => { - const filteredQuestions = message.questions?.filter( - (question) => !isSameQuestionRequest(question, event), - ); - if (message.id !== targetMessageId) { - return filteredQuestions?.length === message.questions?.length - ? message - : { - ...message, - questions: filteredQuestions?.length ? filteredQuestions : undefined, - }; - } - - const nextQuestions = [...(filteredQuestions ?? []), nextQuestion]; - return { - ...message, - questions: nextQuestions, - }; - }); -}; - -const applyQuestionResponse = ( - questions: AgentQuestionRequest[] | undefined, - event: StreamEvent & { type: "question_response" }, -) => - (questions ?? []).map((question) => - question.requestId === event.requestId - ? { - ...question, - status: event.rejected ? "rejected" as const : "answered" as const, - answers: event.answers ?? question.answers, - repliedAt: Date.now(), - error: undefined, - } - : question, - ); - -const createTodoUpdateFromEvent = ( - event: StreamEvent & { type: "todo_update" }, -): AgentTodoUpdate => ({ - sessionId: event.sessionId, - messageId: event.messageId, - todos: event.todos, - createdAt: event.createdAt, -}); - -const normalizeSessionTodos = ( - messages: Message[], - nextTodoUpdate?: AgentTodoUpdate, - targetAssistantMessageId?: string, -) => { - let latestTodoUpdate = nextTodoUpdate; - if (!latestTodoUpdate) { - for (const message of messages) { - if (message.todos) { - latestTodoUpdate = message.todos; - } - } - } - - if (!latestTodoUpdate) { - return messages; - } - - const targetMessageId = - targetAssistantMessageId ?? - [...messages].reverse().find((message) => message.role === "assistant")?.id; - if (!targetMessageId) { - return messages; - } - - let changed = false; - const nextMessages = messages.map((message) => { - if (message.id === targetMessageId) { - if (message.todos === latestTodoUpdate) { - return message; - } - changed = true; - return { - ...message, - todos: latestTodoUpdate, - }; - } - if (!message.todos) { - return message; - } - changed = true; - return { - ...message, - todos: undefined, - }; - }); - - return changed ? nextMessages : messages; -}; - -const rejectOpenPermissionsAfterAbort = ( - permissions: AgentPermissionRequest[] | undefined, -) => { - if (!permissions?.length) return permissions; - let changed = false; - const nextPermissions = permissions.map((permission) => { - if ( - permission.status !== "pending" && - permission.status !== "submitting" && - permission.status !== "error" - ) { - return permission; - } - changed = true; - return { - ...permission, - status: "rejected" as const, - repliedAt: Date.now(), - error: undefined, - }; - }); - return changed ? nextPermissions : permissions; -}; - -const rejectOpenQuestionsAfterAbort = ( - questions: AgentQuestionRequest[] | undefined, -) => { - if (!questions?.length) return questions; - let changed = false; - const nextQuestions = questions.map((question) => { - if ( - question.status !== "pending" && - question.status !== "submitting" && - question.status !== "error" - ) { - return question; - } - changed = true; - return { - ...question, - status: "rejected" as const, - repliedAt: Date.now(), - error: undefined, - }; - }); - return changed ? nextQuestions : questions; -}; - -const finalizeAssistantMessageAfterAbort = (message: Message): Message => { - const completedProgress = completeRunningProgress(message.progress); - const cancelledTodos = cancelRunningTodos(message.todos); - const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions); - const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions); - const hasVisibleOutput = - message.content.trim().length > 0 || - Boolean(message.artifacts?.length) || - Boolean(rejectedPermissions?.length) || - Boolean(rejectedQuestions?.length) || - Boolean(completedProgress?.length) || - Boolean(cancelledTodos); - - if (!hasVisibleOutput) { - return message; - } - - return { - ...message, - content: message.content || "⚠️ **请求已中断**", - isError: true, - progress: completedProgress, - permissions: rejectedPermissions, - questions: rejectedQuestions, - todos: cancelledTodos, - }; -}; - -const createUserMessage = (content: string): Message => { - const id = createId(); - return { - id, - role: "user", - content, - }; -}; - -const createAssistantMessage = (): Message => ({ - id: createId(), - role: "assistant", - content: "", -}); +import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream"; +import type { PermissionReply, StreamEvent } from "@/lib/chatStream"; +import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types"; +import { cloneMessages } from "../GlobalChatbox.utils"; +import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage"; +import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState"; +import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types"; export const useAgentChatSession = ({ projectId, diff --git a/src/components/chat/hooks/useAgentChatSession.types.ts b/src/components/chat/hooks/useAgentChatSession.types.ts new file mode 100644 index 0000000..38e87d2 --- /dev/null +++ b/src/components/chat/hooks/useAgentChatSession.types.ts @@ -0,0 +1,25 @@ +import type { AgentApprovalMode, AgentModel, StreamEvent } from "@/lib/chatStream"; +import type { AgentArtifact, Message } from "../GlobalChatbox.types"; + +export type UseAgentChatSessionOptions = { + projectId?: string | null; + onToolCall: ( + event: StreamEvent & { type: "tool_call" }, + options: { + assistantMessageId: string; + appendArtifact: (messageId: string, artifact: AgentArtifact) => void; + }, + ) => void; + onBeforeSend?: () => void; + getModel?: () => AgentModel; + getApprovalMode?: () => AgentApprovalMode; +}; + +export type PromptRunOptions = { + prompt: string; + sessionIdOverride?: string; + regenerateFromMessageIndex?: number; + preparedMessages?: Message[]; + userMessage?: Message; + assistantMessage?: Message; +};