diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index 923ea2e6..2250b311 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -33,6 +33,9 @@ const TOOL_ICONS: Record = { Skill: 'auto_awesome', } +const WRITER_PREVIEW_MAX_LINES = 120 +const WRITER_PREVIEW_MAX_CHARS = 30000 + export function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) { const [expanded, setExpanded] = useState(false) const t = useTranslation() @@ -186,6 +189,12 @@ function renderDetails( partialInput?: string, ) { if (partialInput) { + if (toolName === 'Write') { + const writerContent = extractPartialJsonStringField(partialInput, 'content') + if (writerContent) { + return renderWriterPreview(writerContent, t) + } + } return renderPartialInput(partialInput, t) } @@ -208,6 +217,110 @@ function renderDetails( ) } +function extractPartialJsonStringField(source: string, field: string): string | null { + const key = `"${field}"` + const keyIndex = source.indexOf(key) + if (keyIndex < 0) return null + const colonIndex = source.indexOf(':', keyIndex + key.length) + if (colonIndex < 0) return null + + let index = colonIndex + 1 + while (index < source.length && /\s/.test(source[index] ?? '')) index += 1 + if (source[index] !== '"') return null + index += 1 + + let value = '' + while (index < source.length) { + const char = source[index] + if (char === '"') return value + if (char !== '\\') { + value += char + index += 1 + continue + } + + const escaped = source[index + 1] + if (escaped === undefined) break + switch (escaped) { + case 'n': + value += '\n' + index += 2 + break + case 'r': + value += '\r' + index += 2 + break + case 't': + value += '\t' + index += 2 + break + case 'b': + value += '\b' + index += 2 + break + case 'f': + value += '\f' + index += 2 + break + case '"': + case '\\': + case '/': + value += escaped + index += 2 + break + case 'u': { + const hex = source.slice(index + 2, index + 6) + if (/^[0-9a-fA-F]{4}$/.test(hex)) { + value += String.fromCharCode(Number.parseInt(hex, 16)) + index += 6 + } else { + index = source.length + } + break + } + default: + value += escaped + index += 2 + break + } + } + return value +} + +function renderWriterPreview( + content: string, + t?: (key: TranslationKey, params?: Record) => string, +) { + const lines = content.split('\n') + const totalLines = lines.length + const visibleLines = lines.length > WRITER_PREVIEW_MAX_LINES + ? lines.slice(-WRITER_PREVIEW_MAX_LINES) + : lines + let visibleContent = visibleLines.join('\n') + const charTruncated = visibleContent.length > WRITER_PREVIEW_MAX_CHARS + if (charTruncated) { + visibleContent = visibleContent.slice(-WRITER_PREVIEW_MAX_CHARS) + } + const lineWindowed = totalLines > visibleLines.length + const isWindowed = lineWindowed || charTruncated + + return ( +
+
+ {t?.('tool.writerPreview') ?? 'Writer'} + {isWindowed ? ( + + {t?.('tool.writerPreviewLatest', { visible: visibleLines.length, total: totalLines }) ?? `Showing latest ${visibleLines.length} of ${totalLines} lines`} + + ) : null} +
+
+        {visibleContent}
+      
+
+ ) +} + function renderPartialInput( partialInput: string, t?: (key: TranslationKey, params?: Record) => string, diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index 57800df0..71331e54 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -79,6 +79,43 @@ describe('chat blocks', () => { expect(container.textContent).toContain('Generating content') }) + it('expands pending Write tool calls into a live writer preview instead of raw JSON', () => { + const { container } = render( + , + ) + + fireEvent.click(screen.getByRole('button')) + + expect(container.textContent).toContain('Writer') + expect(container.textContent).toContain('# 第一章') + expect(container.textContent).toContain('正文正在生成') + expect(container.textContent).not.toContain('"content"') + }) + + it('windows long pending Write previews to the latest content', () => { + const lines = Array.from({ length: 180 }, (_, index) => `line-${index + 1}`) + const escapedContent = lines.join('\\n') + const { container } = render( + , + ) + + fireEvent.click(screen.getByRole('button')) + + expect(container.textContent).toContain('latest') + expect(container.textContent).toContain('line-180') + expect(container.textContent).not.toContain('line-30') + }) + it('shows a collapsed error summary for failed bash commands', () => { const { container } = render( = { 'tool.toolOutput': '工具输出', 'tool.toolInput': '工具输入', 'tool.partialInput': '部分输入', + 'tool.writerPreview': 'Writer', + 'tool.writerPreviewLatest': '正在显示最新 {visible} / {total} 行', 'tool.generatingContent': '正在生成内容', 'tool.preparingEdit': '正在准备编辑', 'tool.preparingTool': '正在准备工具', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 08dae6d5..75058a32 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -1332,6 +1332,8 @@ describe('chatStore history mapping', () => { }) it('renders a pending tool call as soon as the tool stream starts', () => { + vi.useFakeTimers() + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession(), @@ -1359,6 +1361,7 @@ describe('chatStore history mapping', () => { type: 'content_delta', toolInput: '{"file_path":"/private/tmp/ai-code-novel.md","content":"第一章', }) + vi.advanceTimersByTime(60) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { @@ -1395,6 +1398,54 @@ describe('chatStore history mapping', () => { isPending: false, }) expect(toolMessages[0]).not.toHaveProperty('partialInput') + + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + it('batches streaming tool input deltas before updating the pending card', () => { + vi.useFakeTimers() + + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession(), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_start', + blockType: 'tool_use', + toolName: 'Write', + toolUseId: 'write-1', + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_delta', + toolInput: '{"file_path":"/private/tmp/story.md","content":"第一', + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_delta', + toolInput: '章\\n第二段', + }) + + const beforeFlush = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0] + expect(beforeFlush).toMatchObject({ + type: 'tool_use', + isPending: true, + input: {}, + partialInput: '', + }) + + vi.advanceTimersByTime(60) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]).toMatchObject({ + type: 'tool_use', + input: { file_path: '/private/tmp/story.md' }, + partialInput: '{"file_path":"/private/tmp/story.md","content":"第一章\\n第二段', + }) + + vi.runOnlyPendingTimers() + vi.useRealTimers() }) it('refreshes merged slash commands when a live CLI update omits project commands', async () => { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 3088143c..4ac7bcb7 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -288,6 +288,8 @@ function upsertToolUseMessage( // multiple desktop tabs can stream at the same time. const pendingDeltaBySession = new Map() const flushTimerBySession = new Map>() +const pendingToolInputDeltaBySession = new Map() +const toolInputFlushTimerBySession = new Map>() function consumePendingDelta(sessionId: string): string { const flushTimer = flushTimerBySession.get(sessionId) @@ -316,6 +318,33 @@ function clearPendingDelta(sessionId: string): void { pendingDeltaBySession.delete(sessionId) } +function consumePendingToolInputDelta(sessionId: string): string { + const flushTimer = toolInputFlushTimerBySession.get(sessionId) + if (flushTimer) { + clearTimeout(flushTimer) + toolInputFlushTimerBySession.delete(sessionId) + } + const text = pendingToolInputDeltaBySession.get(sessionId) ?? '' + pendingToolInputDeltaBySession.delete(sessionId) + return text +} + +function appendPendingToolInputDelta(sessionId: string, text: string): void { + pendingToolInputDeltaBySession.set( + sessionId, + `${pendingToolInputDeltaBySession.get(sessionId) ?? ''}${text}`, + ) +} + +function clearPendingToolInputDelta(sessionId: string): void { + const flushTimer = toolInputFlushTimerBySession.get(sessionId) + if (flushTimer) { + clearTimeout(flushTimer) + toolInputFlushTimerBySession.delete(sessionId) + } + pendingToolInputDeltaBySession.delete(sessionId) +} + function appendAssistantTextMessage( messages: UIMessage[], content: string, @@ -767,6 +796,7 @@ export const useChatStore = create((set, get) => ({ const text = consumePendingDelta(sessionId) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) })) } + clearPendingToolInputDelta(sessionId) clearPendingTaskToolUseIds(sessionId) clearPendingToolParentUseIds(sessionId) wsManager.disconnect(sessionId) @@ -932,6 +962,7 @@ export const useChatStore = create((set, get) => ({ const text = consumePendingDelta(sessionId) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) })) } + clearPendingToolInputDelta(sessionId) set((s) => { const session = s.sessions[sessionId] if (!session) return s @@ -1090,6 +1121,7 @@ export const useChatStore = create((set, get) => ({ clearMessages: (sessionId) => { clearPendingTaskToolUseIds(sessionId) clearPendingToolParentUseIds(sessionId) + clearPendingToolInputDelta(sessionId) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], activeGoal: null, @@ -1180,6 +1212,7 @@ export const useChatStore = create((set, get) => ({ apiRetry: null, })) } else if (msg.blockType === 'tool_use') { + clearPendingToolInputDelta(sessionId) rememberPendingToolParentUseId(sessionId, msg.toolUseId, msg.parentToolUseId) const toolUseId = msg.toolUseId ?? null const toolName = msg.toolName ?? 'unknown' @@ -1247,31 +1280,39 @@ export const useChatStore = create((set, get) => ({ } } if (msg.toolInput !== undefined) { - update((s) => { - const partialInput = s.streamingToolInput + msg.toolInput - const activeToolUseId = s.activeToolUseId - return { - streamingToolInput: partialInput, - ...(activeToolUseId - ? { - messages: upsertToolUseMessage(s.messages, activeToolUseId, (existing) => { - const toolName = existing?.toolName ?? s.activeToolName ?? 'unknown' - return { - id: existing?.id ?? nextId(), - type: 'tool_use', - toolName, - toolUseId: activeToolUseId, - input: buildPartialToolInputPreview(partialInput, existing?.input), - timestamp: existing?.timestamp ?? Date.now(), - parentToolUseId: existing?.parentToolUseId ?? getPendingToolParentUseId(sessionId, activeToolUseId), - isPending: true, - partialInput, + appendPendingToolInputDelta(sessionId, msg.toolInput) + if (!toolInputFlushTimerBySession.has(sessionId)) { + const timer = setTimeout(() => { + const text = consumePendingToolInputDelta(sessionId) + if (!text) return + update((s) => { + const partialInput = s.streamingToolInput + text + const activeToolUseId = s.activeToolUseId + return { + streamingToolInput: partialInput, + ...(activeToolUseId + ? { + messages: upsertToolUseMessage(s.messages, activeToolUseId, (existing) => { + const toolName = existing?.toolName ?? s.activeToolName ?? 'unknown' + return { + id: existing?.id ?? nextId(), + type: 'tool_use', + toolName, + toolUseId: activeToolUseId, + input: buildPartialToolInputPreview(partialInput, existing?.input), + timestamp: existing?.timestamp ?? Date.now(), + parentToolUseId: existing?.parentToolUseId ?? getPendingToolParentUseId(sessionId, activeToolUseId), + isPending: true, + partialInput, + } + }), } - }), - } - : {}), - } - }) + : {}), + } + }) + }, 50) + toolInputFlushTimerBySession.set(sessionId, timer) + } } break @@ -1298,6 +1339,7 @@ export const useChatStore = create((set, get) => ({ break case 'tool_use_complete': { + clearPendingToolInputDelta(sessionId) const session = get().sessions[sessionId] const toolName = msg.toolName || session?.activeToolName || 'unknown' const toolUseId = msg.toolUseId || session?.activeToolUseId || ''