Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/electron/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@proma/electron",
"version": "0.17.43",
"version": "0.17.44",
"description": "Proma next gen ai software with general agents - Electron App",
"main": "dist/main.cjs",
"author": {
Expand Down
18 changes: 12 additions & 6 deletions apps/electron/src/renderer/components/agent/ContentBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -569,24 +569,30 @@ function ToolUseBlock({ block, allMessages, animate = false, index = 0, dimmed =
interface ThinkingBlockProps {
block: SDKThinkingBlock
dimmed?: boolean
isStreaming?: boolean
}

/** 思考块折叠行数阈值 */
const THINKING_COLLAPSE_LINE_THRESHOLD = 4

function ThinkingBlock({ block, dimmed = false }: ThinkingBlockProps): React.ReactElement {
function ThinkingBlock({ block, dimmed = false, isStreaming = false }: ThinkingBlockProps): React.ReactElement {
const [isExpanded, setIsExpanded] = React.useState(false)
const [shouldCollapse, setShouldCollapse] = React.useState(false)
const contentRef = React.useRef<HTMLDivElement>(null)
const { displayedContent } = useSmoothStream({
content: block.thinking,
isStreaming,
})

// 检测内容是否超过阈值行数(useLayoutEffect:在 paint 前同步执行,避免「展开→收起」闪屏)
// 流式期间避免对每批思考文本同步读取 scrollHeight;这会强制布局且与 Markdown 重渲染叠加。
// 输出完成后再测量,保留历史态的默认折叠行为。
React.useLayoutEffect(() => {
if (!contentRef.current) return
if (isStreaming || !contentRef.current) return
const el = contentRef.current
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 22
const maxHeight = lineHeight * THINKING_COLLAPSE_LINE_THRESHOLD
setShouldCollapse(el.scrollHeight > maxHeight + 10)
}, [block.thinking])
}, [displayedContent, isStreaming])

const toggleExpand = React.useCallback(() => {
setIsExpanded((prev) => !prev)
Expand Down Expand Up @@ -620,7 +626,7 @@ function ThinkingBlock({ block, dimmed = false }: ThinkingBlockProps): React.Rea
)}
>
<MessageResponse className="font-normal prose-strong:font-normal [&_strong]:font-normal [&_b]:font-normal">
{block.thinking}
{displayedContent}
</MessageResponse>
</div>
{shouldCollapse && (
Expand Down Expand Up @@ -707,7 +713,7 @@ export function ContentBlock({ block, allMessages, basePath, basePaths, animate
if (block.type === 'thinking') {
const thinkingBlock = block as SDKThinkingBlock
if (!thinkingBlock.thinking) return null
return <ThinkingBlock block={thinkingBlock} dimmed={dimmed} />
return <ThinkingBlock block={thinkingBlock} dimmed={dimmed} isStreaming={isStreaming} />
}

return null
Expand Down
67 changes: 22 additions & 45 deletions apps/electron/src/renderer/components/agent/ProcessBlockGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import { cn } from '@/lib/utils'
import { getToolDisplayName, getToolIcon } from './tool-utils'
import type {
SDKContentBlock,
SDKMessage,
SDKToolResultBlock,
SDKToolUseBlock,
SDKUserMessage,
SDKTextBlock,
SDKThinkingBlock,
} from '@proma/shared'
Expand Down Expand Up @@ -45,23 +42,6 @@ export type AssistantTurnRenderItem =

interface BuildAssistantTurnRenderItemsOptions {
isStreaming?: boolean
completedToolResultIds?: Set<string>
}

export function buildCompletedToolResultIds(turnMessages: SDKMessage[]): Set<string> {
const ids = new Set<string>()
for (const msg of turnMessages) {
if (msg.type !== 'user') continue
const userMsg = msg as SDKUserMessage
const blocks = userMsg.message?.content
if (!Array.isArray(blocks)) continue
for (const b of blocks) {
if (b.type !== 'tool_result') continue
const rb = b as SDKToolResultBlock
ids.add(rb.tool_use_id)
}
}
return ids
}

function getTrailingTextStartIndex(blocks: SDKContentBlock[]): number | null {
Expand All @@ -75,42 +55,20 @@ function getTrailingTextStartIndex(blocks: SDKContentBlock[]): number | null {
return finalStartIndex
}

function areToolsBeforeIndexCompleted(
blocks: SDKContentBlock[],
endIndex: number,
completedToolResultIds: Set<string> | undefined,
): boolean {
if (!completedToolResultIds) return false

let hasToolUse = false
for (let index = 0; index < endIndex; index++) {
const block = blocks[index]
if (block?.type !== 'tool_use') continue
hasToolUse = true
const toolBlock = block as SDKToolUseBlock
if (!completedToolResultIds.has(toolBlock.id)) return false
}

// 没有 tool_use 时不认为"工具已完成"——避免流式中只有 thinking + 尾部 text
// 时把还可能变成中间过程的 text 提前外置。
return hasToolUse
}

export function buildAssistantTurnRenderItems(
blocks: SDKContentBlock[],
options: BuildAssistantTurnRenderItemsOptions = {},
): AssistantTurnRenderItem[] {
if (blocks.length === 0) return []

// 流式阶段最后的 text 还不稳定,后续工具调用可能会把它变成中间过程。
// 只有当前面所有工具都有结果时,才把尾部 text 视作交付输出提前外置,降低完成瞬间的跳动
// 只要流式末尾出现 text,就按常规消息布局直接展示。若 Agent 之后继续调用工具,
// text 不再位于末尾,会自动回归过程组,避免把中间状态误认为最终答案
const hasProcessBlock = blocks.some((block) => block.type === 'tool_use' || block.type === 'thinking')
const trailingTextStartIndex = getTrailingTextStartIndex(blocks)
const canSplitStreamingFinalOutput = options.isStreaming
&& hasProcessBlock
&& trailingTextStartIndex !== null
&& trailingTextStartIndex > 0
&& areToolsBeforeIndexCompleted(blocks, trailingTextStartIndex, options.completedToolResultIds)

if (options.isStreaming && hasProcessBlock && !canSplitStreamingFinalOutput) {
return buildProcessGroupItems(blocks)
Expand Down Expand Up @@ -143,6 +101,22 @@ function buildProcessGroupItems(blocks: SDKContentBlock[]): AssistantTurnRenderI
}]
}

/**
* Reuse the previous array when streaming only changes a sibling block, such as
* an already-externalized final text block. Delta updates replace changed block
* objects immutably, so reference identity is enough to detect process changes.
*/
export function stabilizeProcessBlockReferences(
previous: SDKContentBlock[],
next: SDKContentBlock[],
): SDKContentBlock[] {
if (previous.length !== next.length) return next
for (let index = 0; index < next.length; index++) {
if (previous[index] !== next[index]) return next
}
return previous
}

function buildProcessGroupSummary(blocks: SDKContentBlock[]): string {
let toolCount = 0
let messageCount = 0
Expand Down Expand Up @@ -235,6 +209,9 @@ export function ProcessBlockGroup({ blocks, isStreaming, renderChildren, isMessa
const contentRef = React.useRef<HTMLDivElement>(null)
const contentInnerRef = React.useRef<HTMLDivElement>(null)
const stableChildrenRef = React.useRef(new Map<string, StableProcessChildCacheEntry>())
const stableProcessBlocksRef = React.useRef(blocks)
stableProcessBlocksRef.current = stabilizeProcessBlockReferences(stableProcessBlocksRef.current, blocks)
const stableProcessBlocks = stableProcessBlocksRef.current
const collapseFrameRef = React.useRef<number | null>(null)
const [measuredHeight, setMeasuredHeight] = React.useState<number | undefined>(undefined)

Expand Down Expand Up @@ -353,7 +330,7 @@ export function ProcessBlockGroup({ blocks, isStreaming, renderChildren, isMessa

React.useLayoutEffect(() => {
if (isStreaming && keepProgressViewport) scrollToLatest()
}, [blocks, isStreaming, keepProgressViewport, scrollToLatest])
}, [stableProcessBlocks, isStreaming, keepProgressViewport, scrollToLatest])

const handleProgressScroll = React.useCallback((event: React.UIEvent<HTMLDivElement>): void => {
if (!isProgressViewportAtBottom(event.currentTarget)) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { ImageLightbox, type LightboxImage } from '@/components/ui/image-lightbo
import { ContentBlock } from './ContentBlock'
import { TurnFileChangesSummary, buildTurnFileNameMap } from './TurnFileChangesSummary'
import { TurnSkillUsageSummary } from './TurnSkillUsageSummary'
import { ProcessBlockGroup, buildAssistantTurnRenderItems, buildCompletedToolResultIds } from './ProcessBlockGroup'
import { ProcessBlockGroup, buildAssistantTurnRenderItems } from './ProcessBlockGroup'
import { extractToolResultText, TASK_TOOL_NAMES } from './task-progress'
import { normalizeThinkTagsInContentBlocks } from './thinking-tag-parser'
// 会话转录的纯逻辑(Turn 分组 / 快照去重 / 预览)已下沉到 @proma/session-core 作为唯一真源。
Expand Down Expand Up @@ -464,15 +464,11 @@ export function AssistantTurnRenderer({ turn, allMessages, basePath, onFork, onR
(b) => b.type === 'text' && 'text' in b && !!(b as { text: string }).text
)

const completedToolResultIds = React.useMemo(() => {
return buildCompletedToolResultIds(turn.turnMessages)
}, [turn.turnMessages])
const renderItems = React.useMemo(() => {
return buildAssistantTurnRenderItems(topLevelBlocks, {
isStreaming,
completedToolResultIds,
})
}, [topLevelBlocks, isStreaming, completedToolResultIds])
}, [topLevelBlocks, isStreaming])

// 本轮「文件名 → 绝对路径」映射:与 footer chips 同源,供正文内联文件引用补全裸文件名
const turnFileMap = React.useMemo(
Expand Down