公司动态

基于React与ink实现命令行AI助手思考内容折叠功能

📅 2026/8/12 23:25:13
基于React与ink实现命令行AI助手思考内容折叠功能
最近在开发一个基于大语言模型的命令行工具时遇到了一个典型的交互体验问题当AI助手进行长链条的“思考”Thinking时终端会被大段的中间推理过程刷屏。这些内容对调试很有价值但对只想看最终结果的用户来说却成了干扰信息。这让我联想到许多现代TUI文本用户界面工具中常见的“折叠”功能于是决定动手为我的项目——姑且称之为“Pi”——实现一个简单的思考折叠机制。本文将详细拆解如何为一个命令行AI助手实现思考内容的折叠与展开功能。无论你是在开发类似deepseek tui的交互工具还是在使用OpenAI、Claude等模型的API构建应用时遇到了thinking内容展示的困扰这篇文章都能提供一套完整的解决方案。我们将从问题分析、设计思路一直讲到具体的代码实现、状态管理以及最佳实践最终你将获得一个可复用的、增强CLI工具用户体验的组件。1. 问题背景与核心需求在AI驱动的命令行工具中尤其是那些集成了复杂推理能力的大模型如GPT-4、DeepSeek等模型在生成最终答案前常常会输出一系列的“思考”内容。这些内容在API中可能体现为content[].thinking字段例如某些支持thinking模式的API或者是工具调用Tool Calls过程中的中间推理步骤。原始交互体验的问题信息过载冗长的思考过程直接打印到终端淹没了最终答案用户需要手动滚动查找。干扰核心输出对于只想获取指令执行结果或简洁答案的用户中间过程是噪音。不利于调试与审查虽然思考过程对开发者很重要但混合在正常输出中难以聚焦分析。解决方案目标实现一个折叠控件默认隐藏详细的思考内容用户可以通过快捷键如Tab或命令展开/收起从而在“简洁模式”和“调试模式”间无缝切换。这类似于IDE中折叠代码块或某些TUI中折叠日志详情的能力。2. 技术选型与环境准备本项目是一个Node.js命令行工具核心依赖如下运行时Node.js ( 18.0.0)UI框架inkreact- 用于在终端构建React组件式的交互界面。文本布局ink内置组件及ink-text。状态管理React Hooks (useState,useEffect)。键盘交互ink的useInputHook。项目初始化与依赖安装首先确保你有一个Node.js项目。如果你从零开始可以按以下步骤操作# 1. 初始化项目 mkdir pi-agent-tui cd pi-agent-tui npm init -y # 2. 安装核心依赖 npm install ink react # 如果需要更复杂的文本样式可以安装 npm install ink-text # 3. 创建入口文件 touch index.js关键依赖说明ink它允许你使用React组件的方式来构建CLI界面管理渲染、状态和用户输入远比手动处理process.stdout和readline要高效和可靠。reactink基于React因此需要安装React。我们的目标不是构建一个完整的pi agent或deepseek tui而是聚焦于实现其中“思考折叠”这一个交互组件。因此下面的代码将是一个独立的、可嵌入的组件。3. 折叠组件的设计与核心状态在设计折叠组件前我们需要明确它的核心状态与属性。组件属性 (Props)title(String): 折叠区域的标题例如“ 模型思考过程”。content(String): 需要被折叠隐藏的详细内容即模型的thinking文本。defaultExpanded(Boolean, 可选): 初始状态是展开还是收起默认为false收起。onToggle(Function, 可选): 当折叠状态改变时的回调函数可用于外部状态同步。组件内部状态 (State)isExpanded(Boolean): 控制内容当前是显示还是隐藏。hasContent(Boolean): 用于判断是否有内容可折叠避免渲染空折叠框。交互逻辑用户按下Tab键时切换isExpanded状态。组件根据isExpanded决定渲染content还是占位提示如“...”或“已折叠”。标题部分始终显示并附带一个状态指示器如[]或[-]。4. 完整实现ThinkingFoldable 组件我们将创建一个名为ThinkingFoldable.js的组件文件。这是实现的核心。// ThinkingFoldable.js import React, { useState, useEffect } from react; import { Text, useInput } from ink; /** * 一个可折叠的思考内容显示组件 * param {Object} props * param {string} props.title - 折叠区域的标题 * param {string} props.content - 需要折叠的详细思考内容 * param {boolean} [props.defaultExpandedfalse] - 初始是否展开 * param {function} [props.onToggle] - 折叠状态切换时的回调函数 */ const ThinkingFoldable ({ title Thinking, content, defaultExpanded false, onToggle }) { // 核心状态是否展开 const [isExpanded, setIsExpanded] useState(defaultExpanded); // 状态是否有内容防止渲染空折叠框 const [hasContent, setHasContent] useState(false); // 监听 content 变化更新 hasContent 状态 useEffect(() { const hasContentNow content content.trim().length 0; setHasContent(hasContentNow); }, [content]); // 处理键盘输入Tab 键切换折叠状态 useInput((input, key) { if (input \t || key.tab) { // 支持 Tab 键 const newState !isExpanded; setIsExpanded(newState); if (onToggle) { onToggle(newState); } } }); // 如果没有内容则不渲染任何东西或者渲染一个极简状态 if (!hasContent) { return null; // 或者可以返回 TextNo thinking content./Text } // 构建状态指示符和标题 const indicator isExpanded ? [-] : []; const fullTitle ${indicator} ${title}; return ( {/* 标题行始终显示 */} Text bold colorcyan {fullTitle} Text colorgray (Press Text boldTab/Text to toggle)/Text /Text {/* 内容区域根据状态决定显示内容还是占位符 */} {isExpanded ? ( // 展开状态显示完整内容通常用灰色等次要颜色 Text colorgray dimColor {content.split(\n).map((line, idx) ( Text key{idx} {line}/Text // 添加缩进 ))} /Text ) : ( // 折叠状态显示省略号或简短提示 Text colorgray italic { ... (content folded)} /Text )} {/* 可选在折叠块后加一个空行增加可读性 */} Text{\n}/Text / ); }; export default ThinkingFoldable;代码逐段解析状态管理 (useState,useEffect):isExpanded是组件的灵魂控制着内容的显隐。hasContent是一个优化项。如果API返回的thinking字段是空字符串或null我们就不渲染整个折叠框保持界面干净。useEffect用于在content属性变化时更新这个状态。键盘交互 (useInput):ink提供的useInputHook让我们能轻松监听终端按键。这里我们监听Tab键\t或key.tab。当按下Tab我们翻转isExpanded状态并调用可选的onToggle回调以便父组件知晓状态变化。条件渲染:如果hasContent为false直接返回null组件不渲染。根据isExpanded的值决定是渲染完整的content并添加缩进还是渲染一个折叠状态的占位符...。样式与提示:使用Text组件的color、bold、italic、dimColor等属性来增强视觉效果。标题部分用醒目的颜色如cyan并明确提示用户使用Tab键切换。思考内容使用gray和dimColor视觉上将其与主输出区分开表明这是辅助信息。5. 在主应用中使用折叠组件现在我们将在主应用文件中使用这个组件。假设我们有一个模拟的AI响应流。// index.js import React, { useState, useEffect } from react; import { render, Text, Box } from ink; import ThinkingFoldable from ./ThinkingFoldable.js; // 模拟一个从API获取的AI响应包含思考过程和最终答案 const mockAIResponse { finalAnswer: 根据计算圆的面积大约是78.54平方单位。, thinking: 用户请求计算半径为5的圆的面积。 我需要回忆圆的面积公式面积 π * r²。 其中π圆周率通常取值3.14159r是半径此处为5。 因此计算步骤为3.14159 * (5 * 5) 3.14159 * 25。 执行乘法3.14159 * 25 78.53975。 四舍五入到两位小数得到78.54。 所以最终答案是78.54平方单位。 }; const App () { const [response, setResponse] useState(null); const [foldableKey, setFoldableKey] useState(0); // 用于强制重渲染的key // 模拟数据加载 useEffect(() { const timer setTimeout(() { setResponse(mockAIResponse); }, 500); return () clearTimeout(timer); }, []); if (!response) { return Text等待AI响应.../Text; } const handleThinkingToggle (isNowExpanded) { // 这里可以记录日志、发送分析事件等 console.log(Thinking section is now ${isNowExpanded ? expanded : collapsed}); // 如果需要可以通过改变key来强制组件重置非必需 // setFoldableKey(prev prev 1); }; return ( Box flexDirectioncolumn padding{1} Text bold AI 助手/Text Text---/Text {/* 1. 显示折叠的思考过程 */} ThinkingFoldable key{thinking-${foldableKey}} // 可选用于控制组件实例 title模型推理过程 content{response.thinking} defaultExpanded{false} // 默认收起 onToggle{handleThinkingToggle} / {/* 2. 显示最终答案 */} Box borderStyleround borderColorgreen paddingX{1} Text bold colorgreen答案/Text Text {response.finalAnswer}/Text /Box Text---/Text Text dimColor提示使用 Tab 键切换思考过程的显示/隐藏。/Text /Box ); }; // 使用 ink 渲染应用 render(App /);运行你的应用确保package.json中配置了启动脚本。// package.json { name: pi-agent-tui, type: module, scripts: { start: node index.js }, dependencies: { ink: ^4.0.0, react: ^18.0.0 } }然后在终端运行node index.js你将看到一个简洁的界面首先显示“模型推理过程”标题且处于折叠状态([])下方是醒目的最终答案。按下Tab键思考内容会展开显示为灰色文本再次按下Tab则收起。6. 高级功能与优化实践基础的折叠功能已经实现但在生产环境中我们可能需要更强大的功能。6.1 处理流式输出与动态内容许多AI API如OpenAI的流式响应是逐字返回的。我们的组件需要能处理动态增长的content。// AdvancedThinkingFoldable.js (部分代码) import React, { useState, useEffect, useRef } from react; const AdvancedThinkingFoldable ({ title, contentStream }) { const [isExpanded, setIsExpanded] useState(false); const [accumulatedContent, setAccumulatedContent] useState(); const contentEndRef useRef(null); // 模拟从流中累积内容 useEffect(() { if (contentStream) { // 假设 contentStream 是一个异步生成器或事件发射器 // 这里简化为一个定时器模拟 const interval setInterval(() { setAccumulatedContent(prev prev 一段新的思考片段...\n); }, 300); return () clearInterval(interval); } }, [contentStream]); // 如果展开自动滚动到底部可选取决于你的渲染库是否支持 useEffect(() { if (isExpanded contentEndRef.current) { // 调用某些TUI库的滚动API或由父容器管理 } }, [isExpanded, accumulatedContent]); return ( Text bold colorcyan onClick{() setIsExpanded(!isExpanded)} {isExpanded ? [-] : []} {title} /Text {isExpanded ( Box maxHeight{10} overflowYauto {/* 限制最大高度并允许滚动 */} Text colorgray {accumulatedContent || 思考内容加载中...} /Text div ref{contentEndRef} / {/* 用于滚动定位的锚点 */} /Box )} / ); };关键点对于流式内容组件内部需要维护一个累积状态(accumulatedContent)。同时考虑为展开的内容区域添加最大高度(maxHeight)和滚动(overflowY)防止超长内容撑爆终端。6.2 多折叠项与全局状态管理当一次对话中有多次工具调用或多次思考时你可能需要管理多个折叠项的状态。// 使用一个状态对象来管理多个折叠项的展开状态 const [expandedStates, setExpandedStates] useState({}); // 渲染多个折叠项 {thinkingSteps.map((step, index) ( ThinkingFoldable key{step-${index}} title{思考步骤 ${index 1}} content{step.content} defaultExpanded{expandedStates[step-${index}] || false} onToggle{(expanded) { setExpandedStates(prev ({ ...prev, [step-${index}]: expanded })); }} / ))} // 甚至可以添加全局控制 Box Text全局控制/Text Text Text colorblue onClick{() setAllExpanded(true)}[展开所有]/Text { | } Text colorblue onClick{() setAllExpanded(false)}[收起所有]/Text /Text /Box6.3 样式与主题定制通过Props允许自定义样式使组件更灵活。// ThinkingFoldable.js 增强版 Props ThinkingFoldable.propTypes { // ... 其他props titleColor: PropTypes.string, contentColor: PropTypes.string, indicatorExpanded: PropTypes.string, indicatorCollapsed: PropTypes.string, foldedHint: PropTypes.string, }; // 在组件内部使用 const indicator isExpanded ? (indicatorExpanded || [-]) : (indicatorCollapsed || []); const foldedText foldedHint || ... (content folded); // 应用颜色 Text bold color{titleColor || cyan}.../Text7. 常见问题与排查思路在实现和使用此类折叠组件时你可能会遇到以下问题问题现象可能原因解决思路按下Tab键无反应1. 键盘事件被其他组件捕获。2.useInput不在组件顶层或条件渲染中失效。3. 终端模拟器对Tab键处理特殊。1. 确保useInput在目标组件内正确调用。2. 尝试改用其他快捷键如CtrlT或F6。3. 使用ink的Box包裹并测试焦点。折叠内容渲染错位或换行异常1. 内容包含ANSI转义码或控制字符。2. 终端宽度不足长文本未正确处理。3.ink的文本布局问题。1. 使用strip-ansi库清理内容字符串。2. 将内容放入Box并设置width或使用Text的wrap属性。3. 确保内容字符串是纯文本避免直接嵌入复杂对象。组件在流式更新时频繁闪烁1. 状态更新导致整个组件重渲染。2.content属性每次都是全新的字符串对象。1. 使用React.memo包装组件避免不必要的重渲染。2. 对于流式更新使用useRef累积内容而非每次用setState更新整个字符串。无法从外部控制折叠状态组件内部useState与外部传入的defaultExpanded不同步。实现“受控组件”模式将isExpanded状态提升到父组件通过expandedprop 传入并通过onToggle回调通知父组件状态变化。在复杂的TUI布局中组件不显示1. 父容器高度为0或布局冲突。2.hasContent逻辑判断过早内容还未加载。1. 检查父组件的flexDirection、height等样式属性。2. 在内容加载前可以显示一个加载中的占位符而不是返回null。8. 最佳实践与工程建议键盘快捷键设计Tab键是折叠/展开的通用选择但需注意它也可能用于焦点切换。确保你的TUI中焦点管理清晰。提供备用快捷键如CtrlE并在界面标题旁给予明确提示。考虑支持CtrlA展开所有和CtrlO收起所有等全局操作。可访问性与用户体验状态指示器要清晰[]/[-]或▼/▶。即使内容折叠也最好显示一个简短的摘要或关键词例如“思考过程包含3个步骤”让用户知道折叠了什么。对于视力障碍用户确保可以通过屏幕阅读器获取状态信息虽然纯TUI中较难实现但这是好的设计原则。性能优化记忆化使用React.memo包装ThinkingFoldable组件防止因父组件无关状态更新导致的重渲染。虚拟化如果思考内容极其冗长数万行考虑实现一个虚拟滚动列表只渲染视口内的行。ink本身不支持但可以结合ink-box或自行计算。防抖渲染对于高速的流式更新不要每次字符到达都触发setState和重渲染可以积累一小段时间如100毫秒的文本再更新。与AI API的集成明确区分“思考”(thinking)和“最终输出”(final_output)。遵循类似OpenAI的reasoning或DeepSeek的thinking字段规范。处理API错误当API返回类似“deepseek returned tool calls without replayable thinking content; continuing with degraded reasoning”的警告时你的UI应该优雅降级例如显示“思考内容不可用”而非一个空折叠框。持久化用户偏好将用户默认的折叠状态展开/收起保存到本地配置文件如~/.pi/config.json下次启动时自动应用。测试策略单元测试测试组件的渲染逻辑有无内容时、状态切换逻辑。集成测试模拟AI API流测试组件在动态内容下的行为。快照测试对组件的展开和收起状态进行UI快照测试防止意外更改。通过实现这样一个思考折叠组件你显著提升了命令行AI工具的用户体验。它平衡了调试的详细性和使用的简洁性是这个类别工具走向成熟和专业化的一个标志性功能。你可以将这个组件轻松集成到你的pi agent、deepseek tui或任何基于Node.js的AI CLI项目中。记住好的工具不仅功能强大更要体贴用户。