目录
[1. 先引入依赖](#1. 先引入依赖)
[2. 核心工具类(含 Point 实体 + 映射逻辑)](#2. 核心工具类(含 Point 实体 + 映射逻辑))
[3.OparetionServiceImpl 实现类](#3.OparetionServiceImpl 实现类)
一、引言
在前文 通义千问3-VL-Plus - 界面交互(本地图片改进)-CSDN博客 中我们完成了对GUI模型的接入,但是我发现定位好像不准确,一番查看后发现,原来读取的是压缩图片的定位,想要获取正确的还需要再进一步修改。
二、代码修改
1. 先引入依赖
<!-- Maven依赖 -->
<dependencies>
<!-- OkHttp网络请求 -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- 图像处理(JDK自带,无需额外引入) -->
<dependency>
<groupId>javax.imageio</groupId>
<artifactId>imageio-api</artifactId>
<version>1.5.1</version>
<scope>provided</scope>
</dependency>
</dependencies>
2. 核心工具类(含 Point 实体 + 映射逻辑)
java
package gzj.spring.ai.util;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.FileUtils;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.math.RoundingMode;
import java.net.HttpURLConnection;
import java.net.URL;
import java.text.DecimalFormat;
/**
* 坐标映射工具类(兼容本地图片/网络URL + Base64转换 + 模型坐标→原始坐标)
*/
public class CoordinateMappingUtil {
// 静态OkHttp客户端(复用连接)
private static final OkHttpClient OK_HTTP_CLIENT = new OkHttpClient().newBuilder().build();
/**
* 坐标点实体类
*/
public static class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
// Getter/Setter
public int getX() { return x; }
public void setX(int x) { this.x = x; }
public int getY() { return y; }
public void setY(int y) { this.y = y; }
@Override
public String toString() {
return "原始图像坐标(x=" + x + ", y=" + y + ")";
}
}
// ====================== 新增:本地图片转Base64 ======================
/**
* 本地图片转Base64(带data:image前缀,兼容GUI-Plus模型输入)
* @param localImagePath 本地图片绝对路径(如E:\\test.png)
* @return 带前缀的Base64字符串
* @throws IOException 文件读取异常
*/
public static String localImageToBase64(String localImagePath) throws IOException {
File imageFile = new File(localImagePath);
// 校验文件存在性
if (!imageFile.exists()) {
throw new FileNotFoundException("本地图片不存在:" + localImagePath);
}
// 读取文件字节
byte[] imageBytes = FileUtils.readFileToByteArray(imageFile);
// Base64编码
String base64Str = Base64.encodeBase64String(imageBytes);
// 自动识别图片格式
String suffix = localImagePath.substring(localImagePath.lastIndexOf(".") + 1).toLowerCase();
if (!suffix.matches("png|jpg|jpeg|bmp")) {
suffix = "png"; // 默认PNG
}
// 拼接data:image前缀
return String.format("data:image/%s;base64,%s", suffix, base64Str);
}
// ====================== 核心:统一读取图片(本地/网络) ======================
/**
* 统一读取图片(自动识别本地路径/网络URL)
* @param imageSource 本地图片路径(如E:\\test.png)或网络URL(http/https开头)
* @return 图片的BufferedImage(用于获取原始宽高)
* @throws IOException 读取异常
*/
private static BufferedImage getImage(String imageSource) throws IOException {
// 判定是否为网络URL
if (imageSource.startsWith("http://") || imageSource.startsWith("https://")) {
return getImageFromUrl(imageSource);
} else {
// 本地图片路径
File localFile = new File(imageSource);
if (!localFile.exists()) {
throw new FileNotFoundException("本地图片不存在:" + imageSource);
}
return ImageIO.read(localFile);
}
}
/**
* 从网络URL读取图片
*/
private static BufferedImage getImageFromUrl(String imageUrl) throws IOException {
// 兼容JDK原生URL(兜底OkHttp)
try {
URL url = new URL(imageUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
conn.setRequestMethod("GET");
if (conn.getResponseCode() == 200) {
return ImageIO.read(conn.getInputStream());
} else {
throw new IOException("网络图片读取失败,响应码:" + conn.getResponseCode());
}
} catch (Exception e) {
// 降级使用OkHttp
Request request = new Request.Builder().url(imageUrl).get().build();
try (Response response = OK_HTTP_CLIENT.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("OkHttp读取图片失败,响应码:" + response.code());
}
byte[] imageBytes = response.body().bytes();
return ImageIO.read(new ByteArrayInputStream(imageBytes));
}
}
}
// ====================== 核心:坐标映射(兼容本地/网络图片) ======================
/**
* 模型坐标映射到原始图像坐标(支持本地图片路径/网络URL)
* @param imageSource 本地图片路径 或 网络URL
* @param modelPoint 模型返回的坐标(基于内部缩放图)
* @param factor 缩放基数(默认28)
* @param maxPixels 最大像素值(默认1280*28*28)
* @param minPixels 最小像素值(默认4*28*28)
* @return 原始图像的绝对坐标
* @throws IOException 读取图片异常
*/
public static Point smartSize(String imageSource, Point modelPoint,
Integer factor, Long maxPixels, Long minPixels) throws IOException {
// 1. 默认参数
int defaultFactor = factor == null ? 28 : factor;
long defaultMaxPixels = maxPixels == null ? 1280 * 28 * 28 : maxPixels;
long defaultMinPixels = minPixels == null ? 4 * 28 * 28 : minPixels;
// 2. 统一读取图片(本地/网络),获取原始尺寸
BufferedImage originalImage = getImage(imageSource);
int originalWidth = originalImage.getWidth();
int originalHeight = originalImage.getHeight();
// 3. 初始调整宽高为factor的整数倍(round取整)
double hBar = Math.round((double) originalHeight / defaultFactor) * defaultFactor;
double wBar = Math.round((double) originalWidth / defaultFactor) * defaultFactor;
// 4. 计算缩放因子beta(根据像素阈值调整)
double totalPixels = hBar * wBar;
double beta = 1.0;
if (totalPixels > defaultMaxPixels) {
beta = Math.sqrt((originalHeight * originalWidth) / (double) defaultMaxPixels);
hBar = Math.floor(originalHeight / beta / defaultFactor) * defaultFactor;
wBar = Math.floor(originalWidth / beta / defaultFactor) * defaultFactor;
} else if (totalPixels < defaultMinPixels) {
beta = Math.sqrt(defaultMinPixels / (originalHeight * originalWidth));
hBar = Math.ceil(originalHeight * beta / defaultFactor) * defaultFactor;
wBar = Math.ceil(originalWidth * beta / defaultFactor) * defaultFactor;
}
// 5. 模型坐标 → 原始坐标(四舍五入)
DecimalFormat df = new DecimalFormat("#");
df.setRoundingMode(RoundingMode.HALF_UP);
int originalX = Integer.parseInt(df.format((double) modelPoint.getX() / wBar * originalWidth));
int originalY = Integer.parseInt(df.format((double) modelPoint.getY() / hBar * originalHeight));
return new Point(originalX, originalY);
}
// ====================== 测试示例 ======================
// public static void main(String[] args) {
// try {
// // 测试1:网络URL场景
// String netImageUrl = "https://p3-flow-imagex-sign.byteimg.com/tos-cn-i-a9rns2rl98/1e8c83f9e6b94f428e21c754d1265406.png";
// Point modelPoint1 = new Point(1205, 278);
// Point netOriginalPoint = smartSize(netImageUrl, modelPoint1, null, null, null);
// System.out.println("【网络图片】模型坐标:" + modelPoint1.getX() + "," + modelPoint1.getY());
// System.out.println("【网络图片】原始坐标:" + netOriginalPoint);
//
// // 测试2:本地图片场景(转Base64 + 坐标映射)
// String localImagePath = "E:\\screenshot\\desktop.png"; // 替换为你的本地路径
// // 本地图片转Base64(供模型调用)
// String base64Str = localImageToBase64(localImagePath);
// System.out.println("【本地图片】Base64(前50字符):" + base64Str.substring(0, 50) + "...");
// // 本地图片坐标映射
// Point modelPoint2 = new Point(1205, 278);
// Point localOriginalPoint = smartSize(localImagePath, modelPoint2, null, null, null);
// System.out.println("【本地图片】模型坐标:" + modelPoint2.getX() + "," + modelPoint2.getY());
// System.out.println("【本地图片】原始坐标:" + localOriginalPoint);
//
// } catch (IOException e) {
// e.printStackTrace();
// }
// }
}
3.OparetionServiceImpl 实现类
java
package gzj.spring.ai.Service.ServiceImpl;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult;
import com.alibaba.dashscope.common.MultiModalMessage;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.exception.UploadFileException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import gzj.spring.ai.Request.OparetionRequest;
import gzj.spring.ai.Service.OparetionService;
import gzj.spring.ai.util.CoordinateMappingUtil;
import io.reactivex.Flowable;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.awt.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.List;
import static com.alibaba.cloud.ai.graph.utils.TryConsumer.log;
import static gzj.spring.ai.util.CoordinateMappingUtil.smartSize;
/**
* @author DELL
*/
@Service
public class OparetionServiceImpl implements OparetionService {
@Value("${spring.ai.dashscope.api-key}")
private String apiKey;
@Value("${spring.ai.dashscope.modelV2:gui-plus}")
private String modelName; // 模型名配置化,便于切换
/**
* 工具方法:本地图片转Base64(带data:image前缀,GUI-Plus支持格式)
*/
private String encodeLocalImageToBase64(String localPath) throws IOException {
Path imagePath = Paths.get(localPath);
// 校验文件存在性
if (!Files.exists(imagePath)) {
throw new IOException("本地图片不存在:" + localPath);
}
// 读取文件并Base64编码(修复原有编码错误)
byte[] imageBytes = Files.readAllBytes(imagePath);
String base64Str = Base64.getEncoder().encodeToString(imageBytes);
// 自动识别图片格式
String suffix = localPath.substring(localPath.lastIndexOf(".") + 1).toLowerCase();
if (!Arrays.asList("png", "jpg", "jpeg").contains(suffix)) {
suffix = "png"; // 默认PNG
}
return String.format("data:image/%s;base64,%s", suffix, base64Str);
}
/**
* 工具方法:构建图片内容(优先级:本地图片 > 网络URL)
*/
private String buildImageContent(OparetionRequest request) throws IOException {
if (request.getLocalImagePath() != null && !request.getLocalImagePath().isEmpty()) {
log.info("使用本地图片:{}", request.getLocalImagePath());
return encodeLocalImageToBase64(request.getLocalImagePath());
} else if (request.getImageUrl() != null && !request.getImageUrl().isEmpty()) {
log.info("使用网络图片URL:{}", request.getImageUrl());
return request.getImageUrl();
} else {
throw new IllegalArgumentException("必须传入imageUrl(网络图片)或localImagePath(本地图片)");
}
}
/**
* 构建GUI-Plus核心提示词(优化为Text Blocks,提升可读性)
*/
private String buildSystemPrompt() {
return """
## 1. 核心角色 (Core Role)
你是一个顶级的AI视觉操作代理。你的任务是分析电脑屏幕截图,理解用户的指令,然后将任务分解为单一、精确的GUI原子操作。
## 2. [CRITICAL] JSON Schema & 绝对规则(必须严格遵守)
你的输出必须是一个**完整、合法、可直接解析**的JSON对象,任何情况下都不能截断、遗漏字段、缺少闭合符号。
### 强制规则
- [R1] 纯JSON输出:回复只能是JSON对象,无任何前缀、后缀、注释、解释性文字。
- [R2] 字段必填性:
- 所有Action的parameters字段必须包含模板中**所有必填键**(如CLICK必须有x、y整数,缺一不可);
- x/y必须是**单个整数**(禁止数组/空值),代表屏幕坐标(像素);
- thought字段必须是一句话,描述思考过程,不能为空。
- [R3] Action值规范:只能是 CLICK/TYPE/SCROLL/KEY_PRESS/FINISH/FAIL(大写、无空格)。
- [R4] JSON格式校验:生成后必须自检------确保大括号闭合、逗号正确、字符串用双引号、数值无引号。
## 3. 工具集 (Available Actions)
### CLICK(必填x、y,可选description)
- 功能: 单击屏幕。
- 坐标规则: x、y是**当前截图的像素坐标**------以截图的左上角为原点,向右为x轴正方向,向下为y轴正方向,坐标值为截图内的实际像素数值(例如截图宽度是1920像素,则x最大为1919)。
- 必须返回如下完整JSON结构(x/y为截图内的实际像素整数):
{
"thought": "一句话描述思考过程",
"action": "CLICK",
"parameters": {
"x": 1753,
"y": 278,
"description": "截图中右上角的豆包应用图标"
}
}
### TYPE(必填text、needs_enter)
- 功能: 输入文本。
- 必须返回如下完整JSON结构:
{
"thought": "一句话描述思考过程",
"action": "TYPE",
"parameters": {
"text": "要输入的文本",
"needs_enter": true/false
}
}
### SCROLL(必填direction、amount)
- 功能: 滚动窗口。
- 必须返回如下完整JSON结构:
{
"thought": "一句话描述思考过程",
"action": "SCROLL",
"parameters": {
"direction": "up/down",
"amount": "small/medium/large"
}
}
### KEY_PRESS(必填key)
- 功能: 按下功能键。
- 必须返回如下完整JSON结构:
{
"thought": "一句话描述思考过程",
"action": "KEY_PRESS",
"parameters": {
"key": "enter/esc/alt+f4等"
}
}
### FINISH(必填message)
- 功能: 任务成功完成。
- 必须返回如下完整JSON结构:
{
"thought": "一句话描述思考过程",
"action": "FINISH",
"parameters": {
"message": "总结任务完成情况"
}
}
### FAIL(必填reason)
- 功能: 任务无法完成。
- 必须返回如下完整JSON结构:
{
"thought": "一句话描述思考过程",
"action": "FAIL",
"parameters": {
"reason": "清晰解释失败原因"
}
}
## 4. 思维与决策框架
1. 目标分析: 用户的最终目标是什么?
2. 屏幕观察: 仅基于截图中的视觉证据决策,看不见的元素不交互。
3. 行动决策: 选择最合适的Action,确保parameters字段完整。
4. 最终校验: 检查JSON是否完整闭合、字段是否必填、格式是否合法,再输出。
""";
}
/**
* 非流式调用(保留原有逻辑,兼容本地图片)
*/
@Override
public String operation(OparetionRequest request) throws ApiException, NoApiKeyException, UploadFileException, IOException {
// 1. 校验核心参数
if (request.getText() == null || request.getText().isEmpty()) {
throw new IllegalArgumentException("用户指令text不能为空");
}
// 2. 初始化客户端
MultiModalConversation conv = new MultiModalConversation();
// 3. 构建系统提示词
MultiModalMessage systemMsg = MultiModalMessage.builder()
.role(Role.SYSTEM.getValue())
.content(Collections.singletonList(Collections.singletonMap("text", buildSystemPrompt())))
.build();
// 4. 构建用户消息(图片+文本)
String imageContent = buildImageContent(request);
MultiModalMessage userMessage = MultiModalMessage.builder()
.role(Role.USER.getValue())
.content(Arrays.asList(
Collections.singletonMap("image", imageContent),
Collections.singletonMap("text", request.getText())
)).build();
// 5. 构建请求参数(修复API Key使用矛盾)
MultiModalConversationParam param = MultiModalConversationParam.builder()
// 统一使用配置文件的API Key
.apiKey(apiKey)
.model(modelName)
.messages(Arrays.asList(systemMsg, userMessage))
.build();
// 6. 同步调用+结果解析(增加空指针防护)
MultiModalConversationResult result = conv.call(param);
if (result == null || result.getOutput() == null ||
result.getOutput().getChoices() == null || result.getOutput().getChoices().isEmpty()) {
log.warn("GUI-Plus返回结果为空");
// 返回空JSON,避免前端解析异常
return "{}";
}
List<Map<String, Object>> content = result.getOutput().getChoices().get(0).getMessage().getContent();
String resText = content != null && !content.isEmpty()
? content.get(0).get("text").toString()
: "{}";
log.info("GUI-Plus非流式调用完成,结果:{}", resText);
// 坐标映射至实际原始图像
try {
// 尝试解析模型返回的JSON文本
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(resText);
int x = jsonNode.path("parameters").path("x").asInt();
int y = jsonNode.path("parameters").path("y").asInt();
CoordinateMappingUtil.Point modelPoint = new CoordinateMappingUtil.Point(x, y);
CoordinateMappingUtil.Point point = CoordinateMappingUtil.smartSize(request.getLocalImagePath(), modelPoint, null, null, null);
log.info("映射后的坐标:{}", point);
} catch (Exception e) {
log.error("解析模型返回结果失败", e);
}
return resText;
}
/**
* 新增:SSE流式调用(实时推送结果)
*/
@Override
public SseEmitter streamOperation(OparetionRequest request) {
// 设置SSE超时时间(30秒)
SseEmitter emitter = new SseEmitter(30000L);
// 超时回调
emitter.onTimeout(() -> handleEmitterError(emitter, "SSE连接超时(30秒)"));
// 客户端关闭回调
emitter.onCompletion(() -> log.info("SSE连接已关闭"));
// 异步执行流式调用(避免阻塞主线程)
new Thread(() -> {
MultiModalConversation conv = new MultiModalConversation();
try {
// 1. 校验参数
if (request.getText() == null || request.getText().isEmpty()) {
throw new IllegalArgumentException("用户指令text不能为空");
}
// 2. 构建图片内容+消息
String imageContent = buildImageContent(request);
MultiModalMessage systemMsg = MultiModalMessage.builder()
.role(Role.SYSTEM.getValue())
.content(Collections.singletonList(Collections.singletonMap("text", buildSystemPrompt())))
.build();
MultiModalMessage userMessage = MultiModalMessage.builder()
.role(Role.USER.getValue())
.content(Arrays.asList(
Collections.singletonMap("image", imageContent),
Collections.singletonMap("text", request.getText())
)).build();
// 3. 构建流式请求参数
MultiModalConversationParam param = MultiModalConversationParam.builder()
.apiKey(apiKey)
.model(modelName)
.messages(Arrays.asList(systemMsg, userMessage))
.maxTokens(2048)
.incrementalOutput(true) // 开启增量输出(流式核心)
.build();
// 4. 流式调用+推送结果
Flowable<MultiModalConversationResult> resultFlow = conv.streamCall(param);
resultFlow.blockingForEach(item -> {
try {
if (item.getOutput() == null || item.getOutput().getChoices() == null || item.getOutput().getChoices().isEmpty()) {
return; // 空结果跳过
}
List<Map<String, Object>> content = item.getOutput().getChoices().get(0).getMessage().getContent();
if (content != null && !content.isEmpty()) {
String text = content.get(0).get("text").toString();
// 推送单条流式数据(event名称:message)
emitter.send(SseEmitter.event().name("message").data(text));
log.debug("推送流式数据:{}", text);
}
} catch (Exception e) {
log.error("推送单条流式数据失败", e);
handleEmitterError(emitter, "数据推送失败:" + e.getMessage());
}
});
// 流式结束标记
emitter.send(SseEmitter.event().name("complete").data("流输出完成"));
emitter.complete();
log.info("GUI-Plus流式调用完成");
} catch (IOException e) {
log.error("读取本地图片失败", e);
handleEmitterError(emitter, "读取本地图片失败:" + e.getMessage());
} catch (ApiException | NoApiKeyException | UploadFileException e) {
log.error("GUI-Plus API调用失败", e);
handleEmitterError(emitter, "API调用失败:" + e.getMessage());
} catch (IllegalArgumentException e) {
log.error("请求参数异常", e);
handleEmitterError(emitter, "参数错误:" + e.getMessage());
} catch (Exception e) {
log.error("流式调用未知异常", e);
handleEmitterError(emitter, "系统异常:" + e.getMessage());
}
}).start();
return emitter;
}
/**
* 工具方法:统一处理SSE异常
*/
private void handleEmitterError(SseEmitter emitter, String errorMsg) {
try {
emitter.send(SseEmitter.event().name("error").data(errorMsg));
emitter.completeWithError(new RuntimeException(errorMsg));
} catch (Exception e) {
log.error("处理SSE发射器异常失败", e);
}
}
}
三、运行结果演示


由于篇幅限制,具体的分析我放在下一篇文章,给大家捋一捋
如果觉得这份修改实用、总结清晰,别忘了动动小手点个赞👍,再关注一下呀~ 后续还会分享更多 AI 接口封装、代码优化的干货技巧,一起解锁更多好用的功能,少踩坑多提效!🥰 你的支持就是我更新的最大动力,咱们下次分享再见呀~🌟