fix: 回合折叠改绑 agent_settled,消除压缩/重试间隙的误收起与正文闪现
This commit is contained in:
@@ -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}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
|
||||||
{answerNodes}
|
{answerNodes}
|
||||||
{streamNodes.length > 0 && <div>{streamNodes}</div>}
|
{streamNodes.length > 0 && <div>{streamNodes}</div>}
|
||||||
{stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && (
|
{stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && (
|
||||||
<Loading label="思考中" variant="Dots" />
|
<Loading label="思考中" variant="Dots" />
|
||||||
)}
|
)}
|
||||||
</>
|
{/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */}
|
||||||
) : (
|
{turnActive && !stream && <Loading label="处理中" variant="Dots" />}
|
||||||
<>
|
|
||||||
{answerNodes}
|
|
||||||
{streamNodes.length > 0 && <div>{streamNodes}</div>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user