fix: 回合折叠改绑 agent_settled,消除压缩/重试间隙的误收起与正文闪现

This commit is contained in:
Rick Ye
2026-09-07 18:50:15 +08:00
parent 29eb566059
commit 54b99a1a58
4 changed files with 70 additions and 33 deletions
+1
View File
@@ -111,6 +111,7 @@ export default function App(): JSX.Element {
messages={state.messages} messages={state.messages}
tools={state.tools} tools={state.tools}
streaming={state.streaming} streaming={state.streaming}
turnActive={state.turnActive}
error={state.error} error={state.error}
retry={state.retry} retry={state.retry}
sessionError={state.sessionError} sessionError={state.sessionError}
@@ -38,6 +38,7 @@ function renderTurns(
messages: UiMessage[], messages: UiMessage[],
tools: Record<string, ToolState>, tools: Record<string, ToolState>,
streaming: boolean, streaming: boolean,
turnActive: boolean,
pendingApprovals: ApprovalRequest[] pendingApprovals: ApprovalRequest[]
): ReactNode[] { ): ReactNode[] {
const items: ReactNode[] = [] const items: ReactNode[] = []
@@ -53,6 +54,7 @@ function renderTurns(
messages={messages.slice(i, j)} messages={messages.slice(i, j)}
tools={tools} tools={tools}
streaming={streaming} streaming={streaming}
turnActive={turnActive}
pendingApprovals={pendingApprovals} pendingApprovals={pendingApprovals}
/> />
) )
@@ -64,6 +66,7 @@ function renderTurns(
message={message} message={message}
tools={tools} tools={tools}
streaming={streaming} streaming={streaming}
turnActive={turnActive}
pendingApprovals={pendingApprovals} pendingApprovals={pendingApprovals}
/> />
) )
@@ -77,6 +80,7 @@ export function ChatView({
messages, messages,
tools, tools,
streaming, streaming,
turnActive,
error, error,
retry, retry,
sessionError, sessionError,
@@ -88,6 +92,7 @@ export function ChatView({
messages: UiMessage[] messages: UiMessage[]
tools: Record<string, ToolState> tools: Record<string, ToolState>
streaming: boolean streaming: boolean
turnActive: boolean
error: string | null error: string | null
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
sessionError: string | null sessionError: string | null
@@ -154,7 +159,7 @@ export function ChatView({
<div ref={scrollRef} onScroll={handleScroll} className="min-h-0 flex-1 overflow-y-auto"> <div ref={scrollRef} onScroll={handleScroll} className="min-h-0 flex-1 overflow-y-auto">
{hasContent ? ( {hasContent ? (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8"> <div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
{renderTurns(messages, tools, streaming, pendingApprovals)} {renderTurns(messages, tools, streaming, turnActive, pendingApprovals)}
</div> </div>
) : ( ) : (
<div className="h-full"> <div className="h-full">
@@ -59,20 +59,25 @@ function useWorkingSeconds(working: boolean): string {
} }
/** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。 /** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。
* 划分规则:最后一轮的正文 = 最终答案(始终可见);这轮里的其余一切 —— * 划分规则:回合结束(turnActive=false,agent_settled 之后)后,最后一轮的正文
* 思考、工具行、以及干活过程中模型穿插写的叙述性正文 —— 全部归入「过程」, * = 最终答案(始终可见);这轮里的其余一切 —— 思考、工具行、以及干活过程中模型
* 样式与普通正文一致,按时序穿插在思考/工具行之间,折叠时随过程一起收起。 * 穿插写的叙述性正文 —— 全部归入「过程」,样式与普通正文一致,按时序穿插在
* 流式中:正在流的消息就是答案的雏形,思考/正文按真实到达顺序分段渲染; * 思考/工具行之间,折叠时随过程一起收起。
* 它结束、下一轮开始后即「沉」入过程区。 */ * 回合进行中不做答案/过程划分(包括工具执行与自动压缩的静默间隙——pi 会在一个
* 回合内发出多次 agent_start/agent_end,streaming 会中途抖动,划分跟着抖就会
* 出现正文在答案区与过程区之间来回跳的闪现);正在流式的消息始终渲染在底部
* 作为答案的雏形,它结束、下一轮开始后即「沉」入过程区。 */
export function AssistantTurn({ export function AssistantTurn({
messages, messages,
tools, tools,
streaming, streaming,
turnActive,
pendingApprovals pendingApprovals
}: { }: {
messages: UiMessage[] messages: UiMessage[]
tools: Record<string, ToolState> tools: Record<string, ToolState>
streaming: boolean streaming: boolean
turnActive: boolean
pendingApprovals: ApprovalRequest[] pendingApprovals: ApprovalRequest[]
}): ReactNode { }): ReactNode {
const processNodes: ReactNode[] = [] const processNodes: ReactNode[] = []
@@ -137,15 +142,16 @@ export function AssistantTurn({
} }
// 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。 // 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。
// stream 不存在时,最后一条已完成消息的正文是答案。 // 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。
const streamPos = messages.findIndex((m) => m.stream) const streamPos = messages.findIndex((m) => m.stream)
const settled = !turnActive
for (let idx = 0; idx < messages.length; idx++) { for (let idx = 0; idx < messages.length; idx++) {
const message = messages[idx] const message = messages[idx]
if (message.stream) { if (message.stream) {
stream = message.stream stream = message.stream
continue continue
} }
consume(message.raw, streamPos === -1 && idx === messages.length - 1) consume(message.raw, settled && streamPos === -1 && idx === messages.length - 1)
} }
flushRows() flushRows()
@@ -154,15 +160,16 @@ export function AssistantTurn({
: false : false
const hasProcess = calls > 0 || thinking > 0 || streamHasThinking const hasProcess = calls > 0 || thinking > 0 || streamHasThinking
// 折叠状态:历史回合默认收起,流式回合默认展开;结束自动收起,可手动切换 // 折叠状态:历史回合默认收起,进行中的回合默认展开;回合真正结束
const [collapsed, setCollapsed] = useState<boolean>(() => !streaming && hasProcess) // (agent_settled → turnActive=false,包含压缩/重试续跑都完成)才自动收起
const prevStreaming = useRef(streaming) const [collapsed, setCollapsed] = useState<boolean>(() => !turnActive && hasProcess)
const prevTurnActive = useRef(turnActive)
useEffect(() => { useEffect(() => {
if (prevStreaming.current && !streaming) setCollapsed(true) if (prevTurnActive.current && !turnActive) setCollapsed(true)
prevStreaming.current = streaming prevTurnActive.current = turnActive
}, [streaming]) }, [turnActive])
const workedFor = useWorkingSeconds(streaming) const workedFor = useWorkingSeconds(turnActive)
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}` const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
// 流式分段:思考段属过程(折叠时隐藏),正文段属答案;均按真实时序渲染 // 流式分段:思考段属过程(折叠时隐藏),正文段属答案;均按真实时序渲染
@@ -216,7 +223,7 @@ export function AssistantTurn({
strokeWidth="2.2" strokeWidth="2.2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="transition-transform" className="transition-transform duration-500"
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }} style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
> >
<path d="M6 9l6 6 6-6" /> <path d="M6 9l6 6 6-6" />
@@ -229,22 +236,31 @@ export function AssistantTurn({
</div> </div>
)} )}
{/* 展开态:过程(思考/工具/叙述正文按时序穿插)+ 最终答案;收起态:只留最终答案 */} {/* 过程区:grid-rows 0fr/1fr 高度动画 + 透明度渐隐,收起/展开更从容 */}
{!collapsed || !hasProcess ? ( <div
<> aria-hidden={collapsed && hasProcess}
className={`grid transition-[grid-template-rows] duration-500 ease-in-out ${
collapsed && hasProcess ? 'pointer-events-none' : ''
}`}
style={{ gridTemplateRows: collapsed && hasProcess ? '0fr' : '1fr' }}
>
<div
className={`min-h-0 overflow-hidden transition-opacity duration-300 ${
collapsed && hasProcess ? 'opacity-0' : 'opacity-100'
}`}
>
{processNodes} {processNodes}
{answerNodes} </div>
{streamNodes.length > 0 && <div>{streamNodes}</div>} </div>
{stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && (
<Loading label="思考中" variant="Dots" /> {/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
)} {answerNodes}
</> {streamNodes.length > 0 && <div>{streamNodes}</div>}
) : ( {stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && (
<> <Loading label="思考中" variant="Dots" />
{answerNodes}
{streamNodes.length > 0 && <div>{streamNodes}</div>}
</>
)} )}
{/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */}
{turnActive && !stream && <Loading label="处理中" variant="Dots" />}
</div> </div>
) )
} }
@@ -253,11 +269,13 @@ export function MessageItem({
message, message,
tools, tools,
streaming, streaming,
turnActive,
pendingApprovals = [] pendingApprovals = []
}: { }: {
message: UiMessage message: UiMessage
tools: Record<string, ToolState> tools: Record<string, ToolState>
streaming: boolean streaming: boolean
turnActive: boolean
pendingApprovals?: ApprovalRequest[] pendingApprovals?: ApprovalRequest[]
}): ReactNode | null { }): ReactNode | null {
const ref = useEntrance<HTMLDivElement>() const ref = useEntrance<HTMLDivElement>()
@@ -277,7 +295,13 @@ export function MessageItem({
if (message.role === 'assistant') { if (message.role === 'assistant') {
return ( return (
<div ref={ref} className="min-w-0 max-w-full"> <div ref={ref} className="min-w-0 max-w-full">
<AssistantTurn messages={[message]} tools={tools} streaming={streaming} pendingApprovals={pendingApprovals} /> <AssistantTurn
messages={[message]}
tools={tools}
streaming={streaming}
turnActive={turnActive}
pendingApprovals={pendingApprovals}
/>
</div> </div>
) )
} }
@@ -33,6 +33,10 @@ interface AgentUiState {
messages: UiMessage[] messages: UiMessage[]
tools: Record<string, ToolState> tools: Record<string, ToolState>
streaming: boolean streaming: boolean
/** 回合是否仍在进行。与 streaming 的区别:pi 在一个回合内可能因自动压缩/重试
* 续跑而发出多次 agent_start/agent_end,streaming 会随之中途抖动;
* turnActive 只在 agent_settled(整个回合真正结束)时归位。 */
turnActive: boolean
queue: { steering: string[]; followUp: string[] } queue: { steering: string[]; followUp: string[] }
error: string | null error: string | null
sessionError: string | null sessionError: string | null
@@ -53,6 +57,7 @@ const initialState: AgentUiState = {
messages: [], messages: [],
tools: {}, tools: {},
streaming: false, streaming: false,
turnActive: false,
queue: { steering: [], followUp: [] }, queue: { steering: [], followUp: [] },
error: null, error: null,
sessionError: null, sessionError: null,
@@ -147,6 +152,7 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
error: null, error: null,
retry: null, retry: null,
streaming: false, streaming: false,
turnActive: false,
queue: { steering: [], followUp: [] }, queue: { steering: [], followUp: [] },
stats: null, stats: null,
sessionState: event.state, sessionState: event.state,
@@ -154,11 +160,12 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
tools: historyToTools(event.messages) tools: historyToTools(event.messages)
} }
case 'agent_start': case 'agent_start':
return { ...state, streaming: true, error: null, retry: null } return { ...state, streaming: true, turnActive: true, error: null, retry: null }
case 'agent_end': case 'agent_end':
// agent_end 不代表回合结束:自动压缩/重试会以 agent_start 续跑同一回合
return { ...state, streaming: false } return { ...state, streaming: false }
case 'agent_settled': case 'agent_settled':
return { ...state, streaming: false, retry: null } return { ...state, streaming: false, turnActive: false, retry: null }
case 'message_start': { case 'message_start': {
// 助手消息开流式占位;用户消息在发送时已乐观插入,此处不重复 // 助手消息开流式占位;用户消息在发送时已乐观插入,此处不重复
if (event.role === 'assistant') { if (event.role === 'assistant') {