diff --git a/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/runtime/ChatAssistantAccumulator.java b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/runtime/ChatAssistantAccumulator.java index c2b74d0..069fab5 100644 --- a/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/runtime/ChatAssistantAccumulator.java +++ b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/runtime/ChatAssistantAccumulator.java @@ -5,55 +5,136 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +/** + * 负责聚合单轮聊天中的 assistant thinking、tool 调用和最终回答, + * 并产出可用于历史回放的结构化 payload。 + */ public class ChatAssistantAccumulator { private final StringBuilder content = new StringBuilder(); private final StringBuilder reasoning = new StringBuilder(); + private final StringBuilder displayReasoning = new StringBuilder(); private final List> chains = new ArrayList<>(); + private final List> messageChain = new ArrayList<>(); + private final List> toolMessages = new ArrayList<>(); + private Map latestToolCallAssistant; + private boolean toolCallBatchOpen; + /** + * 追加当前 assistant 片段的文本内容。 + * + * @param delta 内容增量 + */ public void appendContent(String delta) { if (delta != null && !delta.isEmpty()) { content.append(delta); } } + /** + * 追加当前 assistant 片段的 reasoning 内容。 + * + * @param delta reasoning 增量 + */ public void appendReasoning(String delta) { if (delta != null && !delta.isEmpty()) { reasoning.append(delta); + displayReasoning.append(delta); } } + /** + * 记录 tool call,同时把当前 assistant 片段固化为一条结构化 assistant 消息。 + * + * @param id tool call id + * @param name tool 名称 + * @param arguments tool 参数 + */ public void appendToolCall(String id, String name, Object arguments) { Map chain = findToolChain(id, name); chain.put("status", "TOOL_CALL"); - chain.put("result", arguments); + chain.put("arguments", arguments); + + Map assistantMessage = ensureToolCallAssistantMessage(); + @SuppressWarnings("unchecked") + List> toolCalls = (List>) assistantMessage.computeIfAbsent("toolCalls", + key -> new ArrayList>()); + Map toolCall = new LinkedHashMap<>(); + toolCall.put("id", id); + toolCall.put("name", name); + toolCall.put("arguments", arguments == null ? null : String.valueOf(arguments)); + toolCalls.add(toolCall); } + /** + * 记录 tool result,并附加到结构化消息链中。 + * + * @param id tool call id + * @param name tool 名称 + * @param result tool 结果 + */ public void appendToolResult(String id, String name, Object result) { Map chain = findToolChain(id, name); chain.put("status", "TOOL_RESULT"); chain.put("result", result); + Map toolMessage = ChatRuntimeHistoryPayloadHelper.toolMessage( + id, + result == null ? null : String.valueOf(result) + ); + toolMessages.add(toolMessage); + messageChain.add(ChatRuntimeHistoryPayloadHelper.deepCopyMap(toolMessage)); + toolCallBatchOpen = false; } + /** + * 获取当前 assistant 片段的文本内容。 + * + * @return 文本内容 + */ public String getContent() { return content.toString(); } - public Map buildPayload() { - Map payload = new LinkedHashMap<>(); + /** + * 获取最近一次 tool-call assistant 的 reasoning 内容,供实时内存消息回写复用。 + * + * @return reasoning 内容 + */ + public String getLatestToolCallReasoning() { + return latestToolCallAssistant == null ? null : stringValue(latestToolCallAssistant.get("reasoningContent")); + } + + /** + * 获取最近一次 tool-call assistant 的内容,供实时内存消息回写复用。 + * + * @return 内容 + */ + public String getLatestToolCallContent() { + return latestToolCallAssistant == null ? null : stringValue(latestToolCallAssistant.get("content")); + } + + /** + * 产出结构化 payload。 + * + * @param finalContent 最终 assistant 文本 + * @return payload + */ + public Map buildPayload(String finalContent) { List> payloadChains = new ArrayList<>(); - if (reasoning.length() > 0) { + if (displayReasoning.length() > 0) { Map think = new LinkedHashMap<>(); - think.put("reasoning_content", reasoning.toString()); + think.put("reasoning_content", displayReasoning.toString()); think.put("thinkingStatus", "end"); think.put("thinlCollapse", Boolean.TRUE); payloadChains.add(think); } payloadChains.addAll(chains); - if (!payloadChains.isEmpty()) { - payload.put("chains", payloadChains); + List> payloadMessageChain = ChatRuntimeHistoryPayloadHelper.deepCopyList(messageChain); + Map finalAssistantMessage = buildFinalAssistantMessage(finalContent); + if (!finalAssistantMessage.isEmpty()) { + payloadMessageChain.add(finalAssistantMessage); } - return payload; + return ChatRuntimeHistoryPayloadHelper.buildPayload(payloadMessageChain, toolMessages, payloadChains); } private Map findToolChain(String id, String name) { @@ -71,4 +152,43 @@ public class ChatAssistantAccumulator { chains.add(chain); return chain; } + + private Map ensureToolCallAssistantMessage() { + if (toolCallBatchOpen && latestToolCallAssistant != null && !hasPendingAssistantContent()) { + return latestToolCallAssistant; + } + latestToolCallAssistant = ChatRuntimeHistoryPayloadHelper.assistantMessage( + content.length() == 0 ? null : content.toString(), + reasoning.length() == 0 ? null : reasoning.toString(), + null + ); + messageChain.add(latestToolCallAssistant); + content.setLength(0); + reasoning.setLength(0); + toolCallBatchOpen = true; + return latestToolCallAssistant; + } + + private Map buildFinalAssistantMessage(String finalContent) { + String assistantContent = finalContent; + if ((assistantContent == null || assistantContent.isEmpty()) && content.length() > 0) { + assistantContent = content.toString(); + } + if ((assistantContent == null || assistantContent.isEmpty()) && reasoning.length() == 0) { + return new LinkedHashMap<>(); + } + return ChatRuntimeHistoryPayloadHelper.assistantMessage( + assistantContent, + reasoning.length() == 0 ? null : reasoning.toString(), + null + ); + } + + private boolean hasPendingAssistantContent() { + return content.length() > 0 || reasoning.length() > 0; + } + + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value); + } } diff --git a/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/runtime/ChatRuntimeHistoryPayloadHelper.java b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/runtime/ChatRuntimeHistoryPayloadHelper.java new file mode 100644 index 0000000..aeea4bf --- /dev/null +++ b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/runtime/ChatRuntimeHistoryPayloadHelper.java @@ -0,0 +1,272 @@ +package tech.easyflow.core.runtime; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 聊天运行时历史 payload 的统一读写工具。 + *

+ * 该工具只处理 {@link Map} / {@link List} 结构,避免把 easy-agents 类型泄漏到通用协议模块。 + */ +public final class ChatRuntimeHistoryPayloadHelper { + + public static final String KEY_MESSAGE_CHAIN = "messageChain"; + public static final String KEY_ASSISTANT_MESSAGE = "assistantMessage"; + public static final String KEY_FINAL_ASSISTANT_MESSAGE = "finalAssistantMessage"; + public static final String KEY_TOOL_MESSAGES = "toolMessages"; + public static final String KEY_DISPLAY_CHAINS = "displayChains"; + public static final String KEY_CHAINS = "chains"; + + private ChatRuntimeHistoryPayloadHelper() { + } + + /** + * 构造 assistant 历史消息结构。 + * + * @param content assistant 内容 + * @param reasoningContent assistant reasoning 内容 + * @param toolCalls assistant tool calls + * @return assistant 历史消息结构 + */ + public static Map assistantMessage(String content, + String reasoningContent, + List> toolCalls) { + Map message = new LinkedHashMap<>(); + message.put("role", "assistant"); + if (content != null) { + message.put("content", content); + } + if (reasoningContent != null && !reasoningContent.isEmpty()) { + message.put("reasoningContent", reasoningContent); + } + if (toolCalls != null && !toolCalls.isEmpty()) { + message.put("toolCalls", deepCopyList(toolCalls)); + } + return message; + } + + /** + * 构造 tool 历史消息结构。 + * + * @param toolCallId tool call id + * @param content tool 结果内容 + * @return tool 历史消息结构 + */ + public static Map toolMessage(String toolCallId, String content) { + Map message = new LinkedHashMap<>(); + message.put("role", "tool"); + message.put("toolCallId", toolCallId); + if (content != null) { + message.put("content", content); + } + return message; + } + + /** + * 构造统一的历史 payload。 + * + * @param messageChain 完整 assistant/tool 历史链 + * @param toolMessages tool 历史列表 + * @param displayChains 前端展示链 + * @return payload + */ + public static Map buildPayload(List> messageChain, + List> toolMessages, + List> displayChains) { + Map payload = new LinkedHashMap<>(); + List> safeMessageChain = deepCopyList(messageChain); + List> safeToolMessages = deepCopyList(toolMessages); + List> safeDisplayChains = deepCopyList(displayChains); + if (!safeMessageChain.isEmpty()) { + payload.put(KEY_MESSAGE_CHAIN, safeMessageChain); + Map firstAssistant = findAssistant(safeMessageChain, false); + if (!firstAssistant.isEmpty()) { + payload.put(KEY_ASSISTANT_MESSAGE, firstAssistant); + } + Map lastAssistant = findAssistant(safeMessageChain, true); + if (!lastAssistant.isEmpty() && !lastAssistant.equals(firstAssistant)) { + payload.put(KEY_FINAL_ASSISTANT_MESSAGE, lastAssistant); + } + } + if (!safeToolMessages.isEmpty()) { + payload.put(KEY_TOOL_MESSAGES, safeToolMessages); + } + if (!safeDisplayChains.isEmpty()) { + payload.put(KEY_DISPLAY_CHAINS, safeDisplayChains); + payload.put(KEY_CHAINS, deepCopyList(safeDisplayChains)); + } + return payload; + } + + /** + * 读取结构化消息链。 + * + * @param payload contentPayload + * @return 结构化消息链 + */ + public static List> getMessageChain(Map payload) { + return getMapList(payload == null ? null : payload.get(KEY_MESSAGE_CHAIN)); + } + + /** + * 读取 assistant 历史消息。 + * + * @param payload contentPayload + * @return assistant 历史消息 + */ + public static Map getAssistantMessage(Map payload) { + return getMap(payload == null ? null : payload.get(KEY_ASSISTANT_MESSAGE)); + } + + /** + * 读取最终 assistant 历史消息。 + * + * @param payload contentPayload + * @return 最终 assistant 历史消息 + */ + public static Map getFinalAssistantMessage(Map payload) { + return getMap(payload == null ? null : payload.get(KEY_FINAL_ASSISTANT_MESSAGE)); + } + + /** + * 读取 tool 历史消息列表。 + * + * @param payload contentPayload + * @return tool 历史消息列表 + */ + public static List> getToolMessages(Map payload) { + return getMapList(payload == null ? null : payload.get(KEY_TOOL_MESSAGES)); + } + + /** + * 判断 payload 是否已经包含新结构化历史。 + * + * @param payload contentPayload + * @return true 表示包含新结构 + */ + public static boolean hasStructuredHistory(Map payload) { + return !getMessageChain(payload).isEmpty() + || !getAssistantMessage(payload).isEmpty() + || !getToolMessages(payload).isEmpty(); + } + + /** + * 读取对象为 Map。 + * + * @param value 值 + * @return Map 视图 + */ + @SuppressWarnings("unchecked") + public static Map getMap(Object value) { + if (!(value instanceof Map source)) { + return Collections.emptyMap(); + } + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + if (entry.getKey() != null) { + result.put(String.valueOf(entry.getKey()), entry.getValue()); + } + } + return result; + } + + /** + * 读取对象为 Map 列表。 + * + * @param value 值 + * @return Map 列表 + */ + public static List> getMapList(Object value) { + if (!(value instanceof List source)) { + return Collections.emptyList(); + } + List> result = new ArrayList<>(source.size()); + for (Object item : source) { + Map map = getMap(item); + if (!map.isEmpty()) { + result.add(map); + } + } + return result; + } + + /** + * 深拷贝 Map 列表。 + * + * @param source 原始列表 + * @return 深拷贝后的列表 + */ + public static List> deepCopyList(List> source) { + if (source == null || source.isEmpty()) { + return new ArrayList<>(); + } + List> copy = new ArrayList<>(source.size()); + for (Map item : source) { + copy.add(deepCopyMap(item)); + } + return copy; + } + + /** + * 深拷贝 Map。 + * + * @param source 原始 Map + * @return 深拷贝后的 Map + */ + @SuppressWarnings("unchecked") + public static Map deepCopyMap(Map source) { + if (source == null || source.isEmpty()) { + return new LinkedHashMap<>(); + } + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + Object value = entry.getValue(); + if (value instanceof Map mapValue) { + copy.put(entry.getKey(), deepCopyMap((Map) mapValue)); + } else if (value instanceof List listValue) { + copy.put(entry.getKey(), deepCopyValueList((List) listValue)); + } else { + copy.put(entry.getKey(), value); + } + } + return copy; + } + + private static List deepCopyValueList(List source) { + List copy = new ArrayList<>(source.size()); + for (Object item : source) { + if (item instanceof Map mapItem) { + copy.add(deepCopyMap((Map) mapItem)); + } else if (item instanceof List listItem) { + copy.add(deepCopyValueList((List) listItem)); + } else { + copy.add(item); + } + } + return copy; + } + + private static Map findAssistant(List> messageChain, boolean reverse) { + if (messageChain == null || messageChain.isEmpty()) { + return Collections.emptyMap(); + } + if (reverse) { + for (int i = messageChain.size() - 1; i >= 0; i--) { + Map item = messageChain.get(i); + if ("assistant".equalsIgnoreCase(String.valueOf(item.get("role")))) { + return deepCopyMap(item); + } + } + return Collections.emptyMap(); + } + for (Map item : messageChain) { + if ("assistant".equalsIgnoreCase(String.valueOf(item.get("role")))) { + return deepCopyMap(item); + } + } + return Collections.emptyMap(); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/listener/ChatStreamListener.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/listener/ChatStreamListener.java index 1765eee..318153b 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/listener/ChatStreamListener.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/listener/ChatStreamListener.java @@ -87,6 +87,7 @@ public class ChatStreamListener implements StreamResponseListener { sendToolCallEnvelope(toolCall); } } + applyToolCallHistorySnapshot(aiMessage); aiMessage.setContent(null); memoryPrompt.addMessage(aiMessage); List toolMessages = aiMessageResponse.executeToolCallsAndGetToolMessages(); @@ -275,11 +276,29 @@ public class ChatStreamListener implements StreamResponseListener { message.setContentType("TEXT"); String fullContent = context != null && context.getFullMessage() != null ? context.getFullMessage().getContent() : null; message.setContentText(StringUtil.hasText(fullContent) ? fullContent : assistantAccumulator.getContent()); - message.setContentPayload(assistantAccumulator.buildPayload()); + message.setContentPayload(assistantAccumulator.buildPayload(message.getContentText())); message.setCreatedAt(new Date()); message.setSenderId(runtimeContext.getAssistantId()); message.setSenderName(runtimeContext.getAssistantName()); return message; } + /** + * 在 tool call assistant 写入临时 memory 前,把 reasoning/content 快照回填到消息对象中, + * 以便前端 history 透传和 DeepSeek 下一轮请求都能拿到完整链路。 + * + * @param aiMessage tool call assistant 消息 + */ + private void applyToolCallHistorySnapshot(AiMessage aiMessage) { + if (aiMessage == null) { + return; + } + if (!StringUtil.hasText(aiMessage.getReasoningContent())) { + aiMessage.setReasoningContent(assistantAccumulator.getLatestToolCallReasoning()); + } + if (!StringUtil.hasText(aiMessage.getTextContent())) { + aiMessage.setFullContent(assistantAccumulator.getLatestToolCallContent()); + } + } + } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/memory/ChatRuntimeHistoryMessageMapper.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/memory/ChatRuntimeHistoryMessageMapper.java new file mode 100644 index 0000000..3dabfbf --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/memory/ChatRuntimeHistoryMessageMapper.java @@ -0,0 +1,145 @@ +package tech.easyflow.ai.easyagents.memory; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.Message; +import com.easyagents.core.message.ToolCall; +import com.easyagents.core.message.ToolMessage; +import tech.easyflow.core.runtime.ChatRuntimeHistoryPayloadHelper; +import tech.easyflow.core.runtime.ChatRuntimeMessage; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 负责把聊天运行时持久化 payload 恢复为 easy-agents 消息链。 + */ +public final class ChatRuntimeHistoryMessageMapper { + + private ChatRuntimeHistoryMessageMapper() { + } + + /** + * 从运行时消息中恢复 assistant / tool 历史消息链。 + * + * @param runtimeMessage 运行时消息 + * @return 恢复后的消息列表 + */ + public static List toStructuredMessages(ChatRuntimeMessage runtimeMessage) { + if (runtimeMessage == null) { + return Collections.emptyList(); + } + Map payload = runtimeMessage.getContentPayload(); + List> messageChain = ChatRuntimeHistoryPayloadHelper.getMessageChain(payload); + if (!messageChain.isEmpty()) { + return toMessages(messageChain); + } + if (!ChatRuntimeHistoryPayloadHelper.hasStructuredHistory(payload)) { + return Collections.emptyList(); + } + List messages = new ArrayList<>(); + Map assistantMessage = ChatRuntimeHistoryPayloadHelper.getAssistantMessage(payload); + if (!assistantMessage.isEmpty()) { + AiMessage aiMessage = toAiMessage(assistantMessage); + if (aiMessage != null) { + messages.add(aiMessage); + } + } + List> toolMessages = ChatRuntimeHistoryPayloadHelper.getToolMessages(payload); + messages.addAll(toMessages(toolMessages)); + Map finalAssistantMessage = ChatRuntimeHistoryPayloadHelper.getFinalAssistantMessage(payload); + if (!finalAssistantMessage.isEmpty()) { + AiMessage finalAiMessage = toAiMessage(finalAssistantMessage); + if (finalAiMessage != null) { + messages.add(finalAiMessage); + } + } else if (!messages.isEmpty() + && (runtimeMessage.getContentText() != null && !runtimeMessage.getContentText().isBlank()) + && assistantMessage.containsKey("toolCalls")) { + messages.add(new AiMessage(runtimeMessage.getContentText())); + } + return messages; + } + + private static List toMessages(List> messageChain) { + List messages = new ArrayList<>(); + for (Map item : messageChain) { + String role = String.valueOf(item.get("role")); + if ("assistant".equalsIgnoreCase(role)) { + AiMessage aiMessage = toAiMessage(item); + if (aiMessage != null) { + messages.add(aiMessage); + } + } else if ("tool".equalsIgnoreCase(role)) { + ToolMessage toolMessage = toToolMessage(item); + if (toolMessage != null) { + messages.add(toolMessage); + } + } + } + return messages; + } + + private static AiMessage toAiMessage(Map item) { + if (item == null || item.isEmpty()) { + return null; + } + String content = stringValue(item.get("content")); + String reasoningContent = stringValue(firstNonNull(item.get("reasoningContent"), item.get("reasoning_content"))); + List toolCalls = toToolCalls(ChatRuntimeHistoryPayloadHelper.getMapList(firstNonNull(item.get("toolCalls"), item.get("tool_calls")))); + boolean hasContent = content != null && !content.isBlank(); + boolean hasReasoning = reasoningContent != null && !reasoningContent.isBlank(); + boolean hasToolCalls = toolCalls != null && !toolCalls.isEmpty(); + if (!hasContent && !hasReasoning && !hasToolCalls) { + return null; + } + AiMessage aiMessage = new AiMessage(hasContent ? content : null); + if (hasReasoning) { + aiMessage.setReasoningContent(reasoningContent); + aiMessage.setFullReasoningContent(reasoningContent); + } + if (hasToolCalls) { + aiMessage.setToolCalls(toolCalls); + } + return aiMessage; + } + + private static ToolMessage toToolMessage(Map item) { + if (item == null || item.isEmpty()) { + return null; + } + String content = stringValue(item.get("content")); + String toolCallId = stringValue(firstNonNull(item.get("toolCallId"), item.get("tool_call_id"))); + if ((content == null || content.isBlank()) && (toolCallId == null || toolCallId.isBlank())) { + return null; + } + ToolMessage toolMessage = new ToolMessage(); + toolMessage.setContent(content); + toolMessage.setToolCallId(toolCallId); + return toolMessage; + } + + private static List toToolCalls(List> rawToolCalls) { + if (rawToolCalls == null || rawToolCalls.isEmpty()) { + return null; + } + List toolCalls = new ArrayList<>(rawToolCalls.size()); + for (Map rawToolCall : rawToolCalls) { + ToolCall toolCall = new ToolCall(); + toolCall.setId(stringValue(rawToolCall.get("id"))); + toolCall.setName(stringValue(firstNonNull(rawToolCall.get("name"), rawToolCall.get("toolName")))); + toolCall.setArguments(stringValue(rawToolCall.get("arguments"))); + toolCalls.add(toolCall); + } + return toolCalls; + } + + private static Object firstNonNull(Object first, Object second) { + return first != null ? first : second; + } + + private static String stringValue(Object value) { + return value == null ? null : String.valueOf(value); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/memory/RuntimeChatMemory.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/memory/RuntimeChatMemory.java index e7db63c..500f97d 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/memory/RuntimeChatMemory.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/memory/RuntimeChatMemory.java @@ -10,6 +10,9 @@ import tech.easyflow.core.runtime.ChatRuntimeMessage; import java.util.ArrayList; import java.util.List; +/** + * 从聊天运行时消息恢复 easy-agents 历史消息。 + */ public class RuntimeChatMemory implements ChatMemory { private final Object id; @@ -19,10 +22,7 @@ public class RuntimeChatMemory implements ChatMemory { this.id = id; if (runtimeMessages != null) { for (ChatRuntimeMessage runtimeMessage : runtimeMessages) { - Message message = toMessage(runtimeMessage); - if (message != null) { - this.messages.add(message); - } + this.messages.addAll(toMessages(runtimeMessage)); } } } @@ -55,20 +55,40 @@ public class RuntimeChatMemory implements ChatMemory { return id; } - private Message toMessage(ChatRuntimeMessage runtimeMessage) { - if (runtimeMessage == null || runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) { - return null; + private List toMessages(ChatRuntimeMessage runtimeMessage) { + if (runtimeMessage == null) { + return List.of(); } String role = runtimeMessage.getRole(); if ("assistant".equalsIgnoreCase(role)) { - return new AiMessage(runtimeMessage.getContentText()); + List structuredMessages = ChatRuntimeHistoryMessageMapper.toStructuredMessages(runtimeMessage); + if (!structuredMessages.isEmpty()) { + return structuredMessages; + } + if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) { + return List.of(); + } + return List.of(new AiMessage(runtimeMessage.getContentText())); } if ("system".equalsIgnoreCase(role)) { - return new SystemMessage(runtimeMessage.getContentText()); + if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) { + return List.of(); + } + return List.of(new SystemMessage(runtimeMessage.getContentText())); } if ("tool".equalsIgnoreCase(role)) { - return new SystemMessage(runtimeMessage.getContentText()); + List structuredMessages = ChatRuntimeHistoryMessageMapper.toStructuredMessages(runtimeMessage); + if (!structuredMessages.isEmpty()) { + return structuredMessages; + } + if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) { + return List.of(); + } + return List.of(new SystemMessage(runtimeMessage.getContentText())); } - return new UserMessage(runtimeMessage.getContentText()); + if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) { + return List.of(); + } + return List.of(new UserMessage(runtimeMessage.getContentText())); } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/ChatToolNameHelper.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/ChatToolNameHelper.java new file mode 100644 index 0000000..7aeb02b --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/ChatToolNameHelper.java @@ -0,0 +1,53 @@ +package tech.easyflow.ai.easyagents.tool; + +import org.springframework.util.StringUtils; + +import java.math.BigInteger; +import java.util.regex.Pattern; + +/** + * 聊天工具名称辅助类。 + * + *

聊天工具名称最终会作为 OpenAI-compatible 协议里的 function.name 发给上游模型, + * 因此在启用英文名称时需要确保名称稳定且满足 ASCII 约束。

+ * + * @author Codex + * @since 2026-05-11 + */ +public final class ChatToolNameHelper { + + private static final Pattern SAFE_TOOL_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]+$"); + + private ChatToolNameHelper() { + } + + /** + * 解析聊天工具名称。 + * + * @param needEnglishName 是否优先使用英文名称 + * @param englishName 英文名称 + * @param displayName 展示名称 + * @param fallbackPrefix 安全兜底名前缀 + * @param resourceId 资源 ID + * @return 最终工具名称 + */ + public static String resolveToolName(boolean needEnglishName, + String englishName, + String displayName, + String fallbackPrefix, + BigInteger resourceId) { + if (!needEnglishName) { + return StringUtils.hasText(displayName) ? displayName : buildFallbackName(fallbackPrefix, resourceId); + } + if (StringUtils.hasText(englishName) && SAFE_TOOL_NAME_PATTERN.matcher(englishName).matches()) { + return englishName; + } + return buildFallbackName(fallbackPrefix, resourceId); + } + + private static String buildFallbackName(String fallbackPrefix, BigInteger resourceId) { + String prefix = StringUtils.hasText(fallbackPrefix) ? fallbackPrefix : "tool"; + String suffix = resourceId == null ? "unknown" : resourceId.toString(); + return prefix + "_" + suffix; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java index 2769c76..811f707 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/DocumentCollectionTool.java @@ -67,11 +67,13 @@ public class DocumentCollectionTool extends BaseTool { this.knowledgeId = documentCollection.getId(); this.retrievalMode = retrievalMode == null ? RetrievalMode.HYBRID : retrievalMode; this.chatTimeContext = chatTimeContext; - if (needEnglishName) { - this.name = documentCollection.getEnglishName(); - } else { - this.name = documentCollection.getTitle(); - } + this.name = ChatToolNameHelper.resolveToolName( + needEnglishName, + documentCollection.getEnglishName(), + documentCollection.getTitle(), + "knowledge", + documentCollection.getId() + ); this.description = documentCollection.getDescription(); this.parameters = getDefaultParameters(); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/WorkflowTool.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/WorkflowTool.java index 9d02e6e..7283978 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/WorkflowTool.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/WorkflowTool.java @@ -26,11 +26,13 @@ public class WorkflowTool extends BaseTool { public WorkflowTool(Workflow workflow, boolean needEnglishName, String definitionId) { this.workflowId = workflow.getId(); this.definitionId = definitionId; - if (needEnglishName) { - this.name = workflow.getEnglishName(); - } else { - this.name = workflow.getTitle(); - } + this.name = ChatToolNameHelper.resolveToolName( + needEnglishName, + workflow.getEnglishName(), + workflow.getTitle(), + "workflow", + workflow.getId() + ); this.description = workflow.getDescription(); this.parameters = toParameters(workflow, definitionId); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/ChatRequestParams.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/ChatRequestParams.java index f860c69..12fe841 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/ChatRequestParams.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/ChatRequestParams.java @@ -1,8 +1,11 @@ package tech.easyflow.ai.entity; -import com.easyagents.core.message.*; -import com.alibaba.fastjson.JSON; -import com.alibaba.fastjson2.JSONArray; +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.Message; +import com.easyagents.core.message.SystemMessage; +import com.easyagents.core.message.ToolCall; +import com.easyagents.core.message.ToolMessage; +import com.easyagents.core.message.UserMessage; import com.alibaba.fastjson2.JSONObject; import com.fasterxml.jackson.annotation.JsonProperty; @@ -11,6 +14,9 @@ import java.math.BigInteger; import java.util.ArrayList; import java.util.List; +/** + * Public API 聊天请求参数。 + */ public class ChatRequestParams implements Serializable { private static final long serialVersionUID = 1L; @@ -24,7 +30,7 @@ public class ChatRequestParams implements Serializable { @JsonProperty("messages") public void setMessagesFromJson(List rawMessages) { if (rawMessages == null) { - this.messages = null; + this.messages = new ArrayList<>(); return; } @@ -57,8 +63,8 @@ public class ChatRequestParams implements Serializable { return switch (role) { case "user" -> jsonObj.toJavaObject(UserMessage.class); case "system" -> jsonObj.toJavaObject(SystemMessage.class); - case "assistant" -> jsonObj.toJavaObject(AiMessage.class); - case "tool" -> jsonObj.toJavaObject(ToolMessage.class); + case "assistant" -> toAiMessage(jsonObj); + case "tool" -> toToolMessage(jsonObj); default -> { UserMessage defaultMsg = new UserMessage(); defaultMsg.setContent(content); @@ -67,6 +73,85 @@ public class ChatRequestParams implements Serializable { }; } + /** + * 把 JSON 结构恢复为 assistant 消息,兼容 `reasoningContent` / `reasoning_content` + * 以及 `toolCalls` / `tool_calls` 两种写法。 + * + * @param jsonObj 原始 JSON + * @return assistant 消息 + */ + private AiMessage toAiMessage(JSONObject jsonObj) { + AiMessage aiMessage = jsonObj.toJavaObject(AiMessage.class); + String reasoningContent = jsonObj.getString("reasoningContent"); + if (reasoningContent == null || reasoningContent.isBlank()) { + reasoningContent = jsonObj.getString("reasoning_content"); + } + if (reasoningContent != null && !reasoningContent.isBlank()) { + aiMessage.setReasoningContent(reasoningContent); + aiMessage.setFullReasoningContent(reasoningContent); + } + List toolCalls = parseToolCalls(jsonObj); + if (!toolCalls.isEmpty()) { + aiMessage.setToolCalls(toolCalls); + } + return aiMessage; + } + + /** + * 把 JSON 结构恢复为 tool 消息,兼容 `toolCallId` / `tool_call_id` 两种写法。 + * + * @param jsonObj 原始 JSON + * @return tool 消息 + */ + private ToolMessage toToolMessage(JSONObject jsonObj) { + ToolMessage toolMessage = jsonObj.toJavaObject(ToolMessage.class); + if (toolMessage.getToolCallId() == null || toolMessage.getToolCallId().isBlank()) { + toolMessage.setToolCallId(jsonObj.getString("tool_call_id")); + } + return toolMessage; + } + + /** + * 解析 assistant 上的 tool calls。 + * + * @param jsonObj 原始 JSON + * @return tool call 列表 + */ + private List parseToolCalls(JSONObject jsonObj) { + Object rawToolCalls = jsonObj.get("toolCalls"); + if (rawToolCalls == null) { + rawToolCalls = jsonObj.get("tool_calls"); + } + List toolCalls = new ArrayList<>(); + if (!(rawToolCalls instanceof List rawList)) { + return toolCalls; + } + for (Object rawToolCall : rawList) { + JSONObject toolCallJson = JSONObject.from(rawToolCall); + if (toolCallJson == null) { + continue; + } + ToolCall toolCall = new ToolCall(); + toolCall.setId(toolCallJson.getString("id")); + String toolName = toolCallJson.getString("name"); + if (toolName == null || toolName.isBlank()) { + JSONObject functionJson = toolCallJson.getJSONObject("function"); + if (functionJson != null) { + toolName = functionJson.getString("name"); + if (toolCallJson.getString("arguments") == null || toolCallJson.getString("arguments").isBlank()) { + toolCall.setArguments(functionJson.getString("arguments")); + } + } + } + toolCall.setName(toolName); + if (toolCall.getArguments() == null || toolCall.getArguments().isBlank()) { + toolCall.setArguments(toolCallJson.getString("arguments")); + } + toolCalls.add(toolCall); + } + return toolCalls; + } + public List getMessages() { return messages; } @@ -83,7 +168,11 @@ public class ChatRequestParams implements Serializable { this.botId = botId; } - public String getConversationId() {return conversationId;} + public String getConversationId() { + return conversationId; + } - public void setConversationId(String conversationId) {this.conversationId = conversationId;} -} \ No newline at end of file + public void setConversationId(String conversationId) { + this.conversationId = conversationId; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java index 44baafe..43a451d 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java @@ -75,6 +75,12 @@ public class Model extends ModelBase { deepseekConfig.setApiKey(checkAndGetApiKey()); deepseekConfig.setModel(checkAndGetModelName()); deepseekConfig.setRequestPath(checkAndGetRequestPath()); + deepseekConfig.setSupportThinking(Boolean.TRUE); + deepseekConfig.setThinkingProtocol("deepseek"); + deepseekConfig.setNeedReasoningContentForToolMessage(Boolean.TRUE); + if (getSupportToolMessage() != null) { + deepseekConfig.setSupportToolMessage(getSupportToolMessage()); + } return new DeepseekChatModel(deepseekConfig); default: OpenAIChatConfig openAIChatConfig = new OpenAIChatConfig(); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java index cbf32f8..ced955e 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java @@ -298,7 +298,7 @@ public class BotServiceImpl extends ServiceImpl implements BotSe } UserMessage userMessage = new UserMessage(prompt); userMessage.addTools(buildFunctionList(Maps.of("botId", botId) - .set("needEnglishName", false) + .set("needEnglishName", true) .set("bot", chatCheckResult.getAiBot()) .set("chatTimeContext", chatTimeContext) .set("publishedOnly", chatCheckResult.isPublishedAccess()))); @@ -350,13 +350,15 @@ public class BotServiceImpl extends ServiceImpl implements BotSe BotServiceImpl.ChatCheckResult chatCheckResult, ChatRuntimeContext runtimeContext) { Map modelOptions = chatCheckResult.getModelOptions(); ChatOptions chatOptions = getChatOptions(modelOptions); + Boolean enableDeepThinking = MapUtil.getBoolean(modelOptions, Bot.KEY_ENABLE_DEEP_THINKING, false); + chatOptions.setThinkingEnabled(enableDeepThinking); ChatModel chatModel = chatCheckResult.getChatModel(); String systemPrompt = buildSystemPromptWithFaqImageRule( MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT) ); UserMessage userMessage = new UserMessage(prompt); userMessage.addTools(buildFunctionList(Maps.of("botId", botId) - .set("needEnglishName", false) + .set("needEnglishName", true) .set("needAccountId", false) .set("bot", chatCheckResult.getAiBot()) .set("publishedOnly", chatCheckResult.isPublishedAccess()) diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/memory/RuntimeChatMemoryTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/memory/RuntimeChatMemoryTest.java new file mode 100644 index 0000000..ceac575 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/memory/RuntimeChatMemoryTest.java @@ -0,0 +1,142 @@ +package tech.easyflow.ai.easyagents.memory; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.Message; +import com.easyagents.core.message.ToolCall; +import com.easyagents.core.message.ToolMessage; +import com.easyagents.core.model.client.OpenAIChatMessageSerializer; +import com.easyagents.llm.deepseek.DeepseekConfig; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.core.runtime.ChatAssistantAccumulator; +import tech.easyflow.core.runtime.ChatRuntimeMessage; + +import java.util.List; + +/** + * {@link RuntimeChatMemory} 测试。 + */ +public class RuntimeChatMemoryTest { + + /** + * 应当从结构化 payload 中恢复完整的 assistant/tool 消息链。 + */ + @Test + public void shouldRestoreStructuredAssistantAndToolHistory() { + ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator(); + accumulator.appendReasoning("思考-1"); + accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}"); + accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}"); + accumulator.appendReasoning("思考-2"); + accumulator.appendContent("最终回答"); + + ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage(); + runtimeMessage.setRole("assistant"); + runtimeMessage.setContentText("最终回答"); + runtimeMessage.setContentPayload(accumulator.buildPayload("最终回答")); + + RuntimeChatMemory memory = new RuntimeChatMemory("c1", List.of(runtimeMessage)); + List messages = memory.getMessages(10); + + Assert.assertEquals(3, messages.size()); + Assert.assertTrue(messages.get(0) instanceof AiMessage); + Assert.assertTrue(messages.get(1) instanceof ToolMessage); + Assert.assertTrue(messages.get(2) instanceof AiMessage); + + AiMessage toolAssistant = (AiMessage) messages.get(0); + Assert.assertEquals("思考-1", toolAssistant.getReasoningContent()); + Assert.assertEquals(1, toolAssistant.getToolCalls().size()); + Assert.assertEquals("call-1", toolAssistant.getToolCalls().get(0).getId()); + Assert.assertEquals("{\"query\":\"java\"}", toolAssistant.getToolCalls().get(0).getArguments()); + + ToolMessage toolMessage = (ToolMessage) messages.get(1); + Assert.assertEquals("call-1", toolMessage.getToolCallId()); + Assert.assertEquals("{\"hits\":1}", toolMessage.getContent()); + + AiMessage finalAssistant = (AiMessage) messages.get(2); + Assert.assertEquals("最终回答", finalAssistant.getTextContent()); + Assert.assertEquals("思考-2", finalAssistant.getReasoningContent()); + } + + /** + * 旧结构历史应保持纯文本兼容。 + */ + @Test + public void shouldFallbackToPlainAssistantMessageForLegacyPayload() { + ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage(); + runtimeMessage.setRole("assistant"); + runtimeMessage.setContentText("旧版回答"); + + RuntimeChatMemory memory = new RuntimeChatMemory("c2", List.of(runtimeMessage)); + List messages = memory.getMessages(10); + + Assert.assertEquals(1, messages.size()); + Assert.assertTrue(messages.get(0) instanceof AiMessage); + Assert.assertEquals("旧版回答", ((AiMessage) messages.get(0)).getTextContent()); + } + + /** + * 内部聊天结构化历史回放后,DeepSeek 序列化应继续带上 reasoning_content 与 tool 链。 + */ + @Test + public void shouldSerializeRestoredRuntimeHistoryForDeepseekFollowUp() { + ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator(); + accumulator.appendReasoning("先思考"); + accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}"); + accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}"); + + ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage(); + runtimeMessage.setRole("assistant"); + runtimeMessage.setContentPayload(accumulator.buildPayload(null)); + + RuntimeChatMemory memory = new RuntimeChatMemory("c3", List.of(runtimeMessage)); + List restoredMessages = memory.getMessages(10); + + DeepseekConfig config = new DeepseekConfig(); + config.setSupportThinking(Boolean.TRUE); + config.setThinkingProtocol("deepseek"); + config.setNeedReasoningContentForToolMessage(Boolean.TRUE); + + List> serialized = new OpenAIChatMessageSerializer() + .serializeMessages(restoredMessages, config); + + Assert.assertEquals(2, serialized.size()); + Assert.assertEquals("assistant", serialized.get(0).get("role")); + Assert.assertEquals("先思考", serialized.get(0).get("reasoning_content")); + Assert.assertTrue(serialized.get(0).containsKey("tool_calls")); + Assert.assertEquals("tool", serialized.get(1).get("role")); + Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id")); + } + + /** + * 连续多轮 tool-only assistant 回放后仍应保持独立顺序。 + */ + @Test + public void shouldRestoreSeparatedToolOnlyAssistantSegments() { + ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator(); + accumulator.appendToolCall("call-1", "tool_one", "{\"step\":1}"); + accumulator.appendToolResult("call-1", "tool_one", "{\"ok\":1}"); + accumulator.appendToolCall("call-2", "tool_two", "{\"step\":2}"); + accumulator.appendToolResult("call-2", "tool_two", "{\"ok\":2}"); + + ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage(); + runtimeMessage.setRole("assistant"); + runtimeMessage.setContentPayload(accumulator.buildPayload(null)); + + RuntimeChatMemory memory = new RuntimeChatMemory("c4", List.of(runtimeMessage)); + List messages = memory.getMessages(10); + + Assert.assertEquals(4, messages.size()); + Assert.assertTrue(messages.get(0) instanceof AiMessage); + Assert.assertTrue(messages.get(1) instanceof ToolMessage); + Assert.assertTrue(messages.get(2) instanceof AiMessage); + Assert.assertTrue(messages.get(3) instanceof ToolMessage); + + AiMessage firstAssistant = (AiMessage) messages.get(0); + AiMessage secondAssistant = (AiMessage) messages.get(2); + Assert.assertEquals(1, firstAssistant.getToolCalls().size()); + Assert.assertEquals("call-1", firstAssistant.getToolCalls().get(0).getId()); + Assert.assertEquals(1, secondAssistant.getToolCalls().size()); + Assert.assertEquals("call-2", secondAssistant.getToolCalls().get(0).getId()); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/tool/ChatToolNameHelperTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/tool/ChatToolNameHelperTest.java new file mode 100644 index 0000000..200d2eb --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/tool/ChatToolNameHelperTest.java @@ -0,0 +1,79 @@ +package tech.easyflow.ai.easyagents.tool; + +import org.junit.Assert; +import org.junit.Test; + +import java.math.BigInteger; + +/** + * {@link ChatToolNameHelper} 单元测试。 + * + * @author Codex + * @since 2026-05-11 + */ +public class ChatToolNameHelperTest { + + /** + * 启用英文名时应优先返回合法英文名称。 + */ + @Test + public void resolveToolNameShouldPreferValidEnglishName() { + String name = ChatToolNameHelper.resolveToolName( + true, + "knowledge_search", + "知识库检索", + "knowledge", + BigInteger.valueOf(101) + ); + + Assert.assertEquals("knowledge_search", name); + } + + /** + * 英文名缺失时应回退为稳定安全名称。 + */ + @Test + public void resolveToolNameShouldFallbackWhenEnglishNameMissing() { + String name = ChatToolNameHelper.resolveToolName( + true, + null, + "知识库检索", + "knowledge", + BigInteger.valueOf(101) + ); + + Assert.assertEquals("knowledge_101", name); + } + + /** + * 英文名不满足协议约束时应回退为稳定安全名称。 + */ + @Test + public void resolveToolNameShouldFallbackWhenEnglishNameInvalid() { + String name = ChatToolNameHelper.resolveToolName( + true, + "工作流-A", + "工作流检索", + "workflow", + BigInteger.valueOf(202) + ); + + Assert.assertEquals("workflow_202", name); + } + + /** + * 未启用英文名时应保留展示名称。 + */ + @Test + public void resolveToolNameShouldKeepDisplayNameWhenEnglishNameDisabled() { + String name = ChatToolNameHelper.resolveToolName( + false, + "workflow_search", + "工作流检索", + "workflow", + BigInteger.valueOf(202) + ); + + Assert.assertEquals("工作流检索", name); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/tool/WorkflowToolTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/tool/WorkflowToolTest.java new file mode 100644 index 0000000..0a6244c --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagents/tool/WorkflowToolTest.java @@ -0,0 +1,179 @@ +package tech.easyflow.ai.easyagents.tool; + +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository; +import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository; +import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository; +import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.context.ApplicationContext; +import tech.easyflow.ai.entity.Workflow; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.math.BigInteger; + +/** + * {@link WorkflowTool} 单元测试。 + * + * @author Codex + * @since 2026-05-11 + */ +public class WorkflowToolTest { + + /** + * 启用英文名且字段合法时,应直接使用英文名。 + * + * @throws Exception 反射注入异常 + */ + @Test + public void shouldUseValidEnglishNameWhenEnabled() throws Exception { + ApplicationContext previousContext = getStaticField("applicationContext"); + Object previousBeanFactory = getStaticField("beanFactory"); + try { + setStaticField("beanFactory", null); + setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-1"))); + + WorkflowTool tool = new WorkflowTool(buildWorkflow(101, "workflow_search", "工作流检索"), true, "workflow-1"); + + Assert.assertEquals("workflow_search", tool.getName()); + } finally { + setStaticField("applicationContext", previousContext); + setStaticField("beanFactory", previousBeanFactory); + } + } + + /** + * 启用英文名但字段缺失时,应回退为稳定安全名称。 + * + * @throws Exception 反射注入异常 + */ + @Test + public void shouldFallbackToSafeNameWhenEnglishNameMissing() throws Exception { + ApplicationContext previousContext = getStaticField("applicationContext"); + Object previousBeanFactory = getStaticField("beanFactory"); + try { + setStaticField("beanFactory", null); + setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-2"))); + + WorkflowTool tool = new WorkflowTool(buildWorkflow(202, null, "工作流检索"), true, "workflow-2"); + + Assert.assertEquals("workflow_202", tool.getName()); + } finally { + setStaticField("applicationContext", previousContext); + setStaticField("beanFactory", previousBeanFactory); + } + } + + /** + * 启用英文名但字段非法时,应回退为稳定安全名称。 + * + * @throws Exception 反射注入异常 + */ + @Test + public void shouldFallbackToSafeNameWhenEnglishNameInvalid() throws Exception { + ApplicationContext previousContext = getStaticField("applicationContext"); + Object previousBeanFactory = getStaticField("beanFactory"); + try { + setStaticField("beanFactory", null); + setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-3"))); + + WorkflowTool tool = new WorkflowTool(buildWorkflow(303, "工作流-A", "工作流检索"), true, "workflow-3"); + + Assert.assertEquals("workflow_303", tool.getName()); + } finally { + setStaticField("applicationContext", previousContext); + setStaticField("beanFactory", previousBeanFactory); + } + } + + private Workflow buildWorkflow(long id, String englishName, String title) { + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.valueOf(id)); + workflow.setEnglishName(englishName); + workflow.setTitle(title); + workflow.setDescription("desc-" + id); + return workflow; + } + + private ChainExecutor buildChainExecutor(String definitionId) { + ChainDefinitionRepository definitionRepository = id -> { + ChainDefinition definition = new ChainDefinition(); + definition.setId(definitionId); + return definition; + }; + return new ChainExecutor( + definitionRepository, + new InMemoryChainStateRepository(), + new InMemoryNodeStateRepository() + ); + } + + private ApplicationContext mockApplicationContext(ChainExecutor chainExecutor) { + return (ApplicationContext) Proxy.newProxyInstance( + ApplicationContext.class.getClassLoader(), + new Class[]{ApplicationContext.class}, + (proxy, method, args) -> { + if ("getBean".equals(method.getName()) && args != null && args.length == 1 && args[0] == ChainExecutor.class) { + return chainExecutor; + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + return defaultValue(method.getReturnType()); + } + ); + } + + private static T getStaticField(String name) throws Exception { + Field field = Class.forName("tech.easyflow.common.util.SpringContextUtil").getDeclaredField(name); + field.setAccessible(true); + @SuppressWarnings("unchecked") + T value = (T) field.get(null); + return value; + } + + private static void setStaticField(String name, Object value) throws Exception { + Field field = Class.forName("tech.easyflow.common.util.SpringContextUtil").getDeclaredField(name); + field.setAccessible(true); + field.set(null, value); + } + + private Object defaultValue(Class returnType) { + if (returnType == null || returnType == Void.TYPE) { + return null; + } + if (!returnType.isPrimitive()) { + return null; + } + if (returnType == Boolean.TYPE) { + return false; + } + if (returnType == Character.TYPE) { + return '\0'; + } + if (returnType == Byte.TYPE) { + return (byte) 0; + } + if (returnType == Short.TYPE) { + return (short) 0; + } + if (returnType == Integer.TYPE) { + return 0; + } + if (returnType == Long.TYPE) { + return 0L; + } + if (returnType == Float.TYPE) { + return 0F; + } + if (returnType == Double.TYPE) { + return 0D; + } + return null; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/entity/ChatRequestParamsTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/entity/ChatRequestParamsTest.java new file mode 100644 index 0000000..748f416 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/entity/ChatRequestParamsTest.java @@ -0,0 +1,120 @@ +package tech.easyflow.ai.entity; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.Message; +import com.easyagents.core.message.ToolMessage; +import com.easyagents.core.model.client.OpenAIChatMessageSerializer; +import com.easyagents.llm.deepseek.DeepseekConfig; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.easyagents.memory.PublicBotMessageMemory; +import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; + +import java.util.List; +import java.util.Map; + +/** + * {@link ChatRequestParams} 测试。 + */ +public class ChatRequestParamsTest { + + /** + * 应兼容解析 assistant 的 reasoning 与 OpenAI 风格 tool_calls。 + */ + @Test + public void shouldParseAssistantReasoningAndToolCalls() { + ChatRequestParams params = new ChatRequestParams(); + params.setMessagesFromJson(List.of( + Map.of( + "role", "assistant", + "content", "", + "reasoning_content", "先思考", + "tool_calls", List.of( + Map.of( + "id", "call-1", + "type", "function", + "function", Map.of( + "name", "kb_search", + "arguments", "{\"query\":\"test\"}" + ) + ) + ) + ) + )); + + Message message = params.getMessages().get(0); + Assert.assertTrue(message instanceof AiMessage); + AiMessage aiMessage = (AiMessage) message; + Assert.assertEquals("先思考", aiMessage.getReasoningContent()); + Assert.assertEquals(1, aiMessage.getToolCalls().size()); + Assert.assertEquals("kb_search", aiMessage.getToolCalls().get(0).getName()); + Assert.assertEquals("{\"query\":\"test\"}", aiMessage.getToolCalls().get(0).getArguments()); + } + + /** + * 应兼容解析 tool 消息的下划线字段。 + */ + @Test + public void shouldParseToolMessageWithSnakeCaseToolCallId() { + ChatRequestParams params = new ChatRequestParams(); + params.setMessagesFromJson(List.of( + Map.of( + "role", "tool", + "content", "{\"hits\":1}", + "tool_call_id", "call-2" + ) + )); + + Message message = params.getMessages().get(0); + Assert.assertTrue(message instanceof ToolMessage); + ToolMessage toolMessage = (ToolMessage) message; + Assert.assertEquals("call-2", toolMessage.getToolCallId()); + Assert.assertEquals("{\"hits\":1}", toolMessage.getContent()); + } + + /** + * public-api 多轮 assistant/tool 历史透传后,DeepSeek 后续轮次序列化不应丢字段。 + */ + @Test + public void shouldKeepPublicApiAssistantAndToolHistoryForDeepseekFollowUp() { + ChatRequestParams params = new ChatRequestParams(); + params.setMessagesFromJson(List.of( + Map.of( + "role", "assistant", + "content", "", + "reasoning_content", "先思考", + "tool_calls", List.of( + Map.of( + "id", "call-1", + "type", "function", + "function", Map.of( + "name", "kb_search", + "arguments", "{\"query\":\"test\"}" + ) + ) + ) + ), + Map.of( + "role", "tool", + "content", "{\"hits\":1}", + "tool_call_id", "call-1" + ) + )); + + PublicBotMessageMemory memory = new PublicBotMessageMemory(new ChatSseEmitter(), params.getMessages()); + DeepseekConfig config = new DeepseekConfig(); + config.setSupportThinking(Boolean.TRUE); + config.setThinkingProtocol("deepseek"); + config.setNeedReasoningContentForToolMessage(Boolean.TRUE); + + List> serialized = new OpenAIChatMessageSerializer() + .serializeMessages(memory.getMessages(10), config); + + Assert.assertEquals(2, serialized.size()); + Assert.assertEquals("assistant", serialized.get(0).get("role")); + Assert.assertEquals("先思考", serialized.get(0).get("reasoning_content")); + Assert.assertTrue(serialized.get(0).containsKey("tool_calls")); + Assert.assertEquals("tool", serialized.get(1).get("role")); + Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id")); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/entity/ModelDeepseekConfigTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/entity/ModelDeepseekConfigTest.java new file mode 100644 index 0000000..01bac16 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/entity/ModelDeepseekConfigTest.java @@ -0,0 +1,74 @@ +package tech.easyflow.ai.entity; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.ToolCall; +import com.easyagents.core.message.ToolMessage; +import com.easyagents.core.model.client.OpenAIChatMessageSerializer; +import com.easyagents.llm.deepseek.DeepseekChatModel; +import com.easyagents.llm.deepseek.DeepseekConfig; +import org.junit.Assert; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +/** + * DeepSeek thinking/tool 配置测试。 + */ +public class ModelDeepseekConfigTest { + + /** + * DeepSeek 聊天模型应显式开启 thinking/tool 相关协议开关。 + */ + @Test + public void shouldEnableDeepseekThinkingProtocolFlags() { + ModelProvider provider = new ModelProvider(); + provider.setProviderType("deepseek"); + provider.setProviderName("deepseek"); + + Model model = new Model(); + model.setModelProvider(provider); + model.setEndpoint("https://api.deepseek.com"); + model.setApiKey("sk-test"); + model.setModelName("deepseek-chat"); + model.setRequestPath("/chat/completions"); + + DeepseekChatModel chatModel = (DeepseekChatModel) model.toChatModel(); + DeepseekConfig config = chatModel.getConfig(); + + Assert.assertTrue(config.isSupportThinking()); + Assert.assertEquals("deepseek", config.getThinkingProtocol()); + Assert.assertTrue(config.isNeedReasoningContentForToolMessage()); + } + + /** + * DeepSeek 在 tool call assistant 历史上应序列化 reasoning_content。 + */ + @Test + public void shouldSerializeReasoningContentForToolAssistantMessage() { + DeepseekConfig config = new DeepseekConfig(); + config.setNeedReasoningContentForToolMessage(Boolean.TRUE); + config.setThinkingProtocol("deepseek"); + config.setSupportThinking(Boolean.TRUE); + + AiMessage assistant = new AiMessage(null); + assistant.setReasoningContent("先推理"); + assistant.setToolCalls(List.of(new ToolCall("call-1", "kb_search", "{\"query\":\"java\"}"))); + + ToolMessage toolMessage = new ToolMessage(); + toolMessage.setToolCallId("call-1"); + toolMessage.setContent("{\"hits\":1}"); + + List> serialized = new OpenAIChatMessageSerializer().serializeMessages( + List.of(assistant, toolMessage), + config + ); + + Assert.assertEquals(2, serialized.size()); + Assert.assertEquals("assistant", serialized.get(0).get("role")); + Assert.assertEquals("先推理", serialized.get(0).get("reasoning_content")); + Assert.assertTrue(serialized.get(0).containsKey("tool_calls")); + Assert.assertEquals("tool", serialized.get(1).get("role")); + Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id")); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/core/runtime/ChatAssistantAccumulatorTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/core/runtime/ChatAssistantAccumulatorTest.java new file mode 100644 index 0000000..39723b1 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/core/runtime/ChatAssistantAccumulatorTest.java @@ -0,0 +1,73 @@ +package tech.easyflow.core.runtime; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +/** + * {@link ChatAssistantAccumulator} 测试。 + */ +public class ChatAssistantAccumulatorTest { + + /** + * 应同时保留展示链和可回放的结构化消息链。 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldBuildStructuredPayloadWithDisplayChains() { + ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator(); + accumulator.appendReasoning("思考-1"); + accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}"); + accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}"); + accumulator.appendReasoning("思考-2"); + accumulator.appendContent("最终回答"); + + Map payload = accumulator.buildPayload("最终回答"); + List> messageChain = (List>) payload.get("messageChain"); + List> chains = (List>) payload.get("chains"); + + Assert.assertEquals(3, messageChain.size()); + Assert.assertEquals("assistant", messageChain.get(0).get("role")); + Assert.assertEquals("思考-1", messageChain.get(0).get("reasoningContent")); + Assert.assertEquals("tool", messageChain.get(1).get("role")); + Assert.assertEquals("assistant", messageChain.get(2).get("role")); + Assert.assertEquals("最终回答", messageChain.get(2).get("content")); + Assert.assertEquals("思考-2", messageChain.get(2).get("reasoningContent")); + + Assert.assertFalse(chains.isEmpty()); + Assert.assertEquals("思考-1思考-2", chains.get(0).get("reasoning_content")); + Assert.assertEquals("{\"query\":\"java\"}", chains.get(1).get("arguments")); + Assert.assertEquals("{\"hits\":1}", chains.get(1).get("result")); + } + + /** + * 连续多轮 tool-only assistant 不应被错误合并到同一条 assistant 历史。 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldKeepToolOnlyAssistantSegmentsSeparatedAcrossRecursiveRounds() { + ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator(); + accumulator.appendToolCall("call-1", "tool_one", "{\"step\":1}"); + accumulator.appendToolResult("call-1", "tool_one", "{\"ok\":1}"); + accumulator.appendToolCall("call-2", "tool_two", "{\"step\":2}"); + accumulator.appendToolResult("call-2", "tool_two", "{\"ok\":2}"); + + Map payload = accumulator.buildPayload(null); + List> messageChain = (List>) payload.get("messageChain"); + + Assert.assertEquals(4, messageChain.size()); + Assert.assertEquals("assistant", messageChain.get(0).get("role")); + Assert.assertEquals("tool", messageChain.get(1).get("role")); + Assert.assertEquals("assistant", messageChain.get(2).get("role")); + Assert.assertEquals("tool", messageChain.get(3).get("role")); + + List> firstToolCalls = (List>) messageChain.get(0).get("toolCalls"); + List> secondToolCalls = (List>) messageChain.get(2).get("toolCalls"); + Assert.assertEquals(1, firstToolCalls.size()); + Assert.assertEquals("call-1", firstToolCalls.get(0).get("id")); + Assert.assertEquals(1, secondToolCalls.size()); + Assert.assertEquals("call-2", secondToolCalls.get(0).get("id")); + } +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatlogRuntimeListener.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatlogRuntimeListener.java index b398447..345c751 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatlogRuntimeListener.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatlogRuntimeListener.java @@ -9,6 +9,7 @@ import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; import tech.easyflow.chatlog.service.ChatPersistDispatcher; import tech.easyflow.chatlog.service.ChatSessionQueryService; import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.core.runtime.ChatRuntimeHistoryPayloadHelper; import tech.easyflow.core.runtime.ChatRuntimeContext; import tech.easyflow.core.runtime.ChatRuntimeListener; import tech.easyflow.core.runtime.ChatRuntimeMessage; @@ -81,7 +82,8 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener { Collections.reverse(records); List messages = new ArrayList<>(records.size()); for (ChatMessageRecord record : records) { - if (record.getContentText() == null || record.getContentText().isBlank()) { + if ((record.getContentText() == null || record.getContentText().isBlank()) + && !ChatRuntimeHistoryPayloadHelper.hasStructuredHistory(record.getContentPayload())) { continue; } ChatRuntimeMessage message = new ChatRuntimeMessage();