feat(ai): 新增多模态与结构化输出功能支持

- 引入 Cassandra作为聊天记忆存储后端
- 配置 DashScope 多模态模型支持图文输入- 新增结构化输出控制器,支持 Bean、Map、List 等格式转换
- 添加文生图接口,集成阿里百炼图像生成能力
- 更新应用配置以支持多模态及持久化聊天记录
- 升级依赖项,引入 DashScope SDK 和 Cassandra 支持库
- 创建 ActorFilmography 和 Book 数据模型用于结构化响应
- 调整 ChatClient 配置以适配新的多模态与记忆逻辑
This commit is contained in:
2025-10-27 22:11:08 +08:00
parent d12334fe36
commit 594adcc48d
11 changed files with 331 additions and 10 deletions

View File

@@ -1,5 +1,6 @@
package com.hanserwei.airobot.config;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.hanserwei.airobot.advisor.MyLoggerAdvisor;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
@@ -22,11 +23,13 @@ public class ChatClientConfig {
* @return ChatClient
*/
@Bean
public ChatClient chatClient(DeepSeekChatModel chatModel) {
public ChatClient chatClient(DashScopeChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultSystem("请你扮演一名犬小哈 Java 项目实战专栏的客服人员")
.defaultAdvisors(new SimpleLoggerAdvisor(),
new MyLoggerAdvisor())
// .defaultSystem("请你扮演一名犬小哈 Java 项目实战专栏的客服人员")
.defaultAdvisors(
new SimpleLoggerAdvisor(),
new MyLoggerAdvisor(),
MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
}

View File

@@ -4,6 +4,7 @@ import jakarta.annotation.Resource;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.cassandra.CassandraChatMemoryRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -14,7 +15,7 @@ public class ChatMemoryConfig {
* 记忆存储
*/
@Resource
private ChatMemoryRepository chatMemoryRepository;
private CassandraChatMemoryRepository chatMemoryRepository;
/**
* 初始化一个 ChatMemory 实例,并注入到 Spring 容器中

View File

@@ -0,0 +1,64 @@
package com.hanserwei.airobot.controller;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/v9/ai")
public class MultimodalityController {
@Resource
private DashScopeChatModel chatModel;
/**
* 流式对话
* @param message
* @return
*/
@GetMapping(value = "/generateStream", produces = "text/html;charset=utf-8")
public Flux<String> generateStream(@RequestParam(value = "message") String message) {
// 1. 创建媒体资源
Media image = new Media(
MimeTypeUtils.IMAGE_PNG,
new ClassPathResource("/images/img.png")
);
// 2. 附加选项(可选),如温度值等等
Map<String, Object> metadata = new HashMap<>();
metadata.put("temperature", 0.7);
// 3. 构建多模态消息
UserMessage userMessage = UserMessage.builder()
.text(message)
.media(image)
.metadata(metadata)
.build();
// 4. 构建提示词
Prompt prompt = new Prompt(List.of(userMessage));
// 5. 流式调用
return chatModel.stream(prompt)
.mapNotNull(chatResponse -> {
Generation generation = chatResponse.getResult();
return generation.getOutput().getText();
});
}
}

View File

@@ -8,7 +8,6 @@ import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.template.st.StTemplateRenderer;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;

View File

@@ -0,0 +1,127 @@
package com.hanserwei.airobot.controller;
import com.hanserwei.airobot.model.ActorFilmography;
import com.hanserwei.airobot.model.Book;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.converter.ListOutputConverter;
import org.springframework.ai.converter.MapOutputConverter;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/v8/ai")
public class StructuredOutputController {
@Resource
private ChatClient chatClient;
/**
* 示例1: BeanOutputConverter - 获取演员电影作品集
*
* @param name
* @return
*/
@GetMapping("/actor/films")
public ActorFilmography generate(@RequestParam(value = "name") String name) {
// 一次性返回结果
return chatClient.prompt()
.user(u -> u.text("""
请为演员 {actor} 生成包含5部代表作的电影作品集,
只包含 {actor} 担任主演的电影,不要包含任何解释说明。
""")
.param("actor", name))
.call()
.entity(ActorFilmography.class);
}
/**
* 示例2: MapOutputConverter - 获取编程语言信息
* @param language
* @return
*/
@GetMapping("/language-info")
public Map<String, Object> getLanguageInfo(@RequestParam(value = "lang") String language) {
String userText = """
请提供关于编程语言 {language} 的结构化信息,包含以下字段:"
name (语言名称), "
popularity (流行度排名,整数), "
features (主要特性,字符串数组), "
releaseYear (首次发布年份). "
不要包含任何解释说明,直接输出 JSON 格式数据。
""";
return chatClient.prompt()
.user(u -> u.text(userText).param("language", language))
.call()
.entity(new MapOutputConverter());
}
/**
* 示例3: ListOutputConverter - 获取城市列表
* @param country
* @return
*/
@GetMapping("/city-list")
public List<String> getCityList(@RequestParam(value = "country") String country) {
return chatClient.prompt()
.user(u -> u.text(
"""
列出 {country} 的8个主要城市名称。
不要包含任何编号、解释或其他文本,直接输出城市名称列表。
""")
.param("country", country))
.call()
.entity(new ListOutputConverter(new DefaultConversionService()));
}
/**
* 使用低级 API 的 BeanOutputConverter - 获取书籍信息
* @param bookTitle
* @return
*/
@GetMapping("/book-info")
public Book getBookInfo(@RequestParam(value = "name") String bookTitle) {
// 使用 BeanOutputConverter 定义输出格式
BeanOutputConverter<Book> converter = new BeanOutputConverter<>(Book.class);
// 提示词模板
String template = """
请提供关于书籍《{bookTitle}》的详细信息:
1. 作者姓名
2. 出版年份
3. 主要类型(数组)
4. 书籍描述不少于50字
不要包含任何解释说明,直接按指定格式输出。
{format}
""";
// 创建 Prompt
PromptTemplate promptTemplate = new PromptTemplate(template);
Prompt prompt = promptTemplate.create(Map.of(
"bookTitle", bookTitle,
"format", converter.getFormat()
));
// 调用模型并转换结果
String result = chatClient.prompt(prompt)
.call()
.content();
// 结构化转换
return converter.convert(result);
}
}

View File

@@ -0,0 +1,54 @@
package com.hanserwei.airobot.controller;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesis;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisParam;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisResult;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.utils.JsonUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/v10/ai")
@Slf4j
public class Text2ImgController {
@Value("${spring.ai.dashscope.api-key}")
private String apiKey;
/**
* 调用阿里百炼图生文大模型
* @param prompt 提示词
* @return
*/
@GetMapping("/text2img")
public String text2Image(@RequestParam(value = "prompt") String prompt) {
// 构建文生图参数
ImageSynthesisParam param = ImageSynthesisParam.builder()
.apiKey(apiKey) // 阿里百炼 API Key
.model("wanx2.1-t2i-plus") // 模型名称
.prompt(prompt) // 提示词
.n(1) // 生成图片的数量,这里指定为一张
.size("1024*1024") // 输出图像的分辨率
.build();
// 同步调用 AI 大模型,生成图片
ImageSynthesis imageSynthesis = new ImageSynthesis();
ImageSynthesisResult result = null;
try {
log.info("## 同步调用,请稍等一会...");
result = imageSynthesis.call(param);
} catch (ApiException | NoApiKeyException e){
log.error("", e);
}
// 返回生成的结果(包含图片的 URL 链接)
return JsonUtils.toJson(result);
}
}

View File

@@ -0,0 +1,9 @@
package com.hanserwei.airobot.model;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import java.util.List;
@JsonPropertyOrder({"actor", "movies"})
public record ActorFilmography(String actor, List<String> movies) {
}

View File

@@ -0,0 +1,37 @@
package com.hanserwei.airobot.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Book {
/**
* 书名
*/
private String title;
/**
* 作者
*/
private String author;
/**
* 发布年份
*/
private Integer publishYear;
/**
* 类型
*/
private List<String> genres;
/**
* 简介
*/
private String description;
}

View File

@@ -2,6 +2,10 @@
spring:
application:
name: han-ai-robot-springboot
cassandra:
contact-points: 127.0.0.1 # Cassandra 集群节点地址(可配置多个,用逗号分隔)
port: 9042 # 端口号
local-datacenter: datacenter1 # 必须与集群配置的数据中心名称一致(大小写敏感)
ai:
deepseek:
api-key: ENC(MROXdiEHmWk08koE63bTzFqW52MaXLpMkM9Cyl40Ubj+Lw1yKeZuHLEcs6jTFY8ditY4gJ1365LMAY8Z9G1uwfYFYaYdb3NyijplX7GuDZA=) # 填写 DeepSeek Api Key, 改成你自己的
@@ -34,8 +38,17 @@ spring:
api-key: ENC(cMgcKZkFllyE88DIbGwLKot9Vg02co+gsmY8L8o4/o3UjhcmqO4lJzFU35Sx0n+qFG8pDL0wBjoWrT8X6BuRw9vNlQhY1LgRWHaF9S1zzyM=)
chat:
options:
model: qwen-plus
model: qwen-omni-turbo
temperature: 0.7
multi-model: true
chat:
memory:
repository:
cassandra:
keyspace: han_ai_robot
table: t_ai_chat_memory
time-to-live: 1095d
initialize-schema: true
jasypt:
encryptor:

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB