Skip to content

Commit 005c8ea

Browse files
committed
refactor(ui): 优化消息区域渲染性能并添加日志记录
重构消息区域组件,使用 Static 组件隔离已完成消息避免重复渲染 为关键操作添加调试日志记录 移除 Header 组件中未使用的 isInitialized 属性 优化终端宽度更新逻辑,添加防抖处理
1 parent 0affcf9 commit 005c8ea

7 files changed

Lines changed: 174 additions & 51 deletions

File tree

src/ui/components/BladeInterface.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useMemoizedFn } from 'ahooks';
22
import { Box, useApp, useStdout } from 'ink';
3+
import { debounce } from 'lodash-es';
34
import React, { useEffect, useRef, useState } from 'react';
45
import { ConfigManager } from '../../config/ConfigManager.js';
56
import { PermissionMode, type SetupConfig } from '../../config/types.js';
@@ -197,9 +198,11 @@ export const BladeInterface: React.FC<BladeInterfaceProps> = ({
197198
}, [otherProps.resume, handleResume]);
198199

199200
// 获取终端宽度
200-
const updateTerminalWidth = useMemoizedFn(() => {
201-
setTerminalWidth(stdout.columns || 80);
202-
});
201+
const updateTerminalWidth = useMemoizedFn(
202+
debounce(() => {
203+
setTerminalWidth(stdout.columns || 80);
204+
}, 200)
205+
);
203206

204207
useEffect(() => {
205208
updateTerminalWidth();
@@ -429,8 +432,7 @@ export const BladeInterface: React.FC<BladeInterfaceProps> = ({
429432
/>
430433
) : (
431434
<>
432-
<Header isInitialized={readyForChat} />
433-
435+
{/* MessageArea 内部直接引入 Header,作为 Static 的第一个子项 */}
434436
<MessageArea
435437
sessionState={sessionState}
436438
terminalWidth={terminalWidth}
@@ -457,7 +459,6 @@ export const BladeInterface: React.FC<BladeInterfaceProps> = ({
457459
selectedIndex={selectedSuggestionIndex}
458460
visible={showSuggestions}
459461
/>
460-
461462
<ChatStatusBar
462463
messageCount={sessionState.messages.length}
463464
hasApiKey={readyForChat}

src/ui/components/Header.tsx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,12 @@ import Gradient from 'ink-gradient';
44
import React from 'react';
55
import { getCopyright } from '../../utils/packageInfo.js';
66

7-
interface HeaderProps {
8-
isInitialized: boolean; // API 是否已初始化
9-
}
10-
117
/**
128
* 应用头部组件
139
* 显示 ASCII Logo、使用指南
10+
*
1411
*/
15-
export const Header: React.FC<HeaderProps> = React.memo(({ isInitialized }) => {
12+
export const Header: React.FC = React.memo(() => {
1613
return (
1714
<Box flexDirection="column" paddingX={2} paddingTop={1} paddingBottom={1}>
1815
{/* Logo 使用 BigText + Gradient 渲染渐变效果 */}
@@ -40,13 +37,6 @@ export const Header: React.FC<HeaderProps> = React.memo(({ isInitialized }) => {
4037
<Text color="white">2. 使用 /init 创建项目配置文件</Text>
4138
<Text color="white">3. 输入 /help 查看所有 slash 命令</Text>
4239
</Box>
43-
44-
{/* API 密钥警告(如未初始化) */}
45-
{!isInitialized && (
46-
<Box>
47-
<Text color="yellow">⚠️ API 密钥未配置,请先设置环境变量 BLADE_API_KEY</Text>
48-
</Box>
49-
)}
5040
</Box>
5141
);
5242
});

src/ui/components/MessageArea.tsx

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { Box } from 'ink';
2-
import React from 'react';
1+
import { Box, Static } from 'ink';
2+
import React, { ReactNode, useMemo } from 'react';
33
import type { TodoItem } from '../../tools/builtin/todo/types.js';
4-
import type { SessionState } from '../contexts/SessionContext.js';
4+
import type { SessionMessage, SessionState } from '../contexts/SessionContext.js';
5+
import { Header } from './Header.js';
56
import { MessageRenderer } from './MessageRenderer.js';
67
import { TodoPanel } from './TodoPanel.js';
78

@@ -15,32 +16,83 @@ interface MessageAreaProps {
1516
/**
1617
* 消息区域组件
1718
* 负责显示消息列表
19+
*
20+
* 性能优化:
21+
* - 使用 Ink 的 Static 组件将已完成的消息隔离,避免重新渲染
22+
* - 只有正在流式传输的消息会重新渲染
23+
* - 使用 useMemo 缓存计算结果
24+
*
25+
* 布局优化:
26+
* - Header 作为 Static 的第一个子项,确保永远在历史消息顶部
1827
*/
1928
export const MessageArea: React.FC<MessageAreaProps> = React.memo(
2029
({ sessionState, terminalWidth, todos = [], showTodoPanel = false }) => {
21-
// 找到最后一条用户消息的索引(TodoPanel 将显示在这之后)
22-
const lastUserMessageIndex = sessionState.messages.findLastIndex(
23-
(msg) => msg.role === 'user'
30+
// 分离已完成的消息和正在流式传输的消息
31+
const { completedMessages, streamingMessage } = useMemo(() => {
32+
const messages = sessionState.messages;
33+
const isStreaming = sessionState.isThinking;
34+
35+
// 如果正在思考,最后一条消息视为流式传输中
36+
if (isStreaming && messages.length > 0) {
37+
return {
38+
completedMessages: messages.slice(0, -1),
39+
streamingMessage: messages[messages.length - 1],
40+
};
41+
}
42+
43+
// 否则所有消息都是已完成的
44+
return {
45+
completedMessages: messages,
46+
streamingMessage: null,
47+
};
48+
}, [sessionState.messages, sessionState.isThinking]);
49+
50+
// 找到最后一条用户消息的索引(用于 TodoPanel 定位)
51+
const lastUserMessageIndex = useMemo(() => {
52+
return sessionState.messages.findLastIndex((msg) => msg.role === 'user');
53+
}, [sessionState.messages]);
54+
55+
// 渲染单个消息(用于 Static 和 dynamic 区域)
56+
const renderMessage = (msg: SessionMessage, index: number, isPending = false) => (
57+
<Box key={msg.id} flexDirection="column">
58+
<MessageRenderer
59+
content={msg.content}
60+
role={msg.role}
61+
terminalWidth={terminalWidth}
62+
metadata={msg.metadata as Record<string, unknown>}
63+
isPending={isPending}
64+
/>
65+
{/* 在最后一条用户消息后显示 TodoPanel */}
66+
{index === lastUserMessageIndex && showTodoPanel && todos.length > 0 && (
67+
<TodoPanel todos={todos} visible={true} compact={false} />
68+
)}
69+
</Box>
2470
);
2571

72+
// 构建 Static items:Header + 已完成的消息
73+
const staticItems = useMemo(() => {
74+
const items: ReactNode[] = [];
75+
76+
// 1. Header 作为第一个子项(永远在顶部)
77+
items.push(<Header key="header" />);
78+
79+
// 2. 已完成的消息
80+
completedMessages.forEach((msg, index) => {
81+
items.push(renderMessage(msg, index));
82+
});
83+
84+
return items;
85+
}, [completedMessages, lastUserMessageIndex, showTodoPanel, todos]);
86+
2687
return (
2788
<Box flexDirection="column" flexGrow={1} paddingX={2}>
2889
<Box flexDirection="column" flexGrow={1}>
29-
{/* 渲染消息列表 */}
30-
{sessionState.messages.map((msg, index) => (
31-
<Box key={msg.id} flexDirection="column">
32-
<MessageRenderer
33-
content={msg.content}
34-
role={msg.role}
35-
terminalWidth={terminalWidth}
36-
metadata={msg.metadata as Record<string, unknown>}
37-
/>
38-
{/* 在最后一条用户消息后显示 TodoPanel */}
39-
{index === lastUserMessageIndex && showTodoPanel && todos.length > 0 && (
40-
<TodoPanel todos={todos} visible={true} compact={false} />
41-
)}
42-
</Box>
43-
))}
90+
{/* 静态区域:Header + 已完成的消息永不重新渲染 */}
91+
<Static items={staticItems}>{(item) => item}</Static>
92+
93+
{/* 动态区域:只有流式传输的消息会重新渲染 */}
94+
{streamingMessage &&
95+
renderMessage(streamingMessage, completedMessages.length, true)}
4496
</Box>
4597
</Box>
4698
);

src/ui/components/MessageRenderer.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface MessageRendererProps {
2525
role: MessageRole;
2626
terminalWidth: number;
2727
metadata?: Record<string, unknown>; // 🆕 用于 tool-progress 等消息的元数据
28+
isPending?: boolean; // 🆕 标记是否为流式传输中的消息
2829
}
2930

3031
// 获取角色样式配置
@@ -517,8 +518,12 @@ const ToolDetailRenderer: React.FC<{
517518
* 主要的消息渲染器组件
518519
*/
519520
export const MessageRenderer: React.FC<MessageRendererProps> = React.memo(
520-
({ content, role, terminalWidth, metadata }) => {
521-
const roleStyle = getRoleStyle(role, metadata);
521+
({ content, role, terminalWidth, metadata, isPending = false }) => {
522+
// 使用 useMemo 缓存角色样式计算
523+
const roleStyle = React.useMemo(
524+
() => getRoleStyle(role, metadata),
525+
[role, metadata]
526+
);
522527
const { color, prefix } = roleStyle;
523528

524529
// 处理 tool 消息的详细内容
@@ -549,8 +554,11 @@ export const MessageRenderer: React.FC<MessageRendererProps> = React.memo(
549554
}
550555
}
551556

552-
// 正常渲染(摘要行或无 detail 的消息)
553-
const blocks = parseMarkdown(content);
557+
// 使用 useMemo 缓存 Markdown 解析结果(仅在 content 变化时重新解析)
558+
const blocks = React.useMemo(
559+
() => parseMarkdown(content),
560+
[content]
561+
);
554562

555563
return (
556564
<Box flexDirection="column" marginBottom={1}>
@@ -614,5 +622,16 @@ export const MessageRenderer: React.FC<MessageRendererProps> = React.memo(
614622
})}
615623
</Box>
616624
);
625+
},
626+
// 自定义比较函数:只在关键属性变化时才重新渲染
627+
(prevProps, nextProps) => {
628+
return (
629+
prevProps.content === nextProps.content &&
630+
prevProps.role === nextProps.role &&
631+
prevProps.terminalWidth === nextProps.terminalWidth &&
632+
prevProps.isPending === nextProps.isPending &&
633+
// 对 metadata 进行浅比较
634+
JSON.stringify(prevProps.metadata) === JSON.stringify(nextProps.metadata)
635+
);
617636
}
618637
);

src/ui/contexts/SessionContext.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import React, {
66
useContext,
77
useReducer,
88
} from 'react';
9+
import { createLogger, LogCategory } from '../../logging/Logger.js';
10+
11+
// 创建 SessionContext 专用 Logger
12+
const logger = createLogger(LogCategory.UI);
913

1014
/**
1115
* 消息角色类型
@@ -144,13 +148,18 @@ export function SessionProvider({ children }: { children: ReactNode }) {
144148
const [state, dispatch] = useReducer(sessionReducer, initialState);
145149

146150
const addUserMessage = useCallback((content: string) => {
151+
logger.debug('[DIAG] addUserMessage called:', {
152+
contentLength: content.length,
153+
contentPreview: content.substring(0, 50) + (content.length > 50 ? '...' : ''),
154+
});
147155
const message: SessionMessage = {
148156
id: `user-${Date.now()}-${Math.random()}`,
149157
role: 'user',
150158
content,
151159
timestamp: Date.now(),
152160
};
153161
dispatch({ type: 'ADD_MESSAGE', payload: message });
162+
logger.debug('[DIAG] User message dispatched:', { messageId: message.id });
154163
}, []);
155164

156165
const addAssistantMessage = useCallback((content: string) => {

src/ui/hooks/useCommandHandler.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -487,22 +487,48 @@ export const useCommandHandler = (
487487
isProcessing
488488
);
489489

490+
logger.debug('[DIAG] executeCommand called:', {
491+
command: command.substring(0, 50) + (command.length > 50 ? '...' : ''),
492+
isProcessing,
493+
isEmpty: !command.trim(),
494+
});
495+
496+
if (!command.trim()) {
497+
logger.debug('[DIAG] Command blocked: empty command');
498+
return;
499+
}
500+
501+
if (isProcessing) {
502+
logger.debug('[DIAG] Command blocked: isProcessing=true (another command is running)');
503+
return;
504+
}
505+
490506
if (command.trim() && !isProcessing) {
491507
const trimmedCommand = command.trim();
492508

509+
logger.debug('[DIAG] Command accepted, starting execution');
510+
493511
// 清空上一轮对话的 todos
494512
appDispatch({ type: 'SET_TODOS', payload: [] });
495513

496514
setIsProcessing(true);
497515
dispatch({ type: 'SET_THINKING', payload: true });
498516

517+
logger.debug('[DIAG] States set: isProcessing=true, isThinking=true');
518+
499519
try {
520+
logger.debug('[DIAG] Calling handleCommandSubmit');
500521
const result = await handleCommandSubmit(
501522
trimmedCommand,
502523
addUserMessage,
503524
addAssistantMessage
504525
);
505526

527+
logger.debug('[DIAG] handleCommandSubmit completed:', {
528+
success: result.success,
529+
hasError: !!result.error,
530+
});
531+
506532
if (!result.success && result.error) {
507533
dispatch({ type: 'SET_ERROR', payload: result.error });
508534
}
@@ -511,14 +537,29 @@ export const useCommandHandler = (
511537
const errorMessage = error instanceof Error ? error.message : '未知错误';
512538
dispatch({ type: 'SET_ERROR', payload: `执行失败: ${errorMessage}` });
513539
} finally {
514-
setIsProcessing(false);
515-
setLoopState({
516-
active: false,
517-
turn: 0,
518-
maxTurns: 50,
519-
currentTool: undefined,
520-
});
521-
dispatch({ type: 'SET_THINKING', payload: false });
540+
// 为每个状态重置添加独立的错误处理,防止某一个失败导致其他状态无法重置
541+
try {
542+
setIsProcessing(false);
543+
} catch (e) {
544+
console.error('[CRITICAL] Failed to reset isProcessing:', e);
545+
}
546+
547+
try {
548+
setLoopState({
549+
active: false,
550+
turn: 0,
551+
maxTurns: 50,
552+
currentTool: undefined,
553+
});
554+
} catch (e) {
555+
console.error('[CRITICAL] Failed to reset loopState:', e);
556+
}
557+
558+
try {
559+
dispatch({ type: 'SET_THINKING', payload: false });
560+
} catch (e) {
561+
console.error('[CRITICAL] Failed to reset isThinking:', e);
562+
}
522563
}
523564
}
524565
}

0 commit comments

Comments
 (0)