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