feat(chat): ChatGPT tool calling + fix cross-turn message pollution

This commit is contained in:
matevip 2026-04-11 08:46:21 +08:00
parent fb634c1d05
commit bfd1cbac56
10 changed files with 450 additions and 124 deletions

View File

@ -10,13 +10,17 @@ import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.*;
/**
* ChatGPT 会员模型 实现 Spring AI ChatModel 接口
* 内部通过 ChatGPTResponsesClient 调用 chatgpt.com/backend-api
* ChatGPT 会员模型 实现 Spring AI ChatModel 接口
* 支持 tool calling Prompt ChatOptions 中提取 toolCallbacks
* 传递给 ChatGPTResponsesClient并将响应中的 function_call 转换为 ToolCall
*/
@Slf4j
public class ChatGPTChatModel implements ChatModel {
@ -36,9 +40,10 @@ public class ChatGPTChatModel implements ChatModel {
List<Message> messages = prompt.getInstructions();
String model = resolveModel(prompt);
Double temp = resolveTemperature(prompt);
List<ToolDefinition> toolDefs = extractToolDefinitions(prompt);
log.debug("ChatGPT call: model={}, messages={}", model, messages.size());
String content = client.call(model, messages, temp);
log.debug("[ChatGPT] call: model={}, messages={}, tools={}", model, messages.size(), toolDefs.size());
String content = client.call(model, messages, temp, toolDefs);
Generation generation = new Generation(new AssistantMessage(content),
ChatGenerationMetadata.builder().finishReason("stop").build());
@ -51,14 +56,65 @@ public class ChatGPTChatModel implements ChatModel {
List<Message> messages = prompt.getInstructions();
String model = resolveModel(prompt);
Double temp = resolveTemperature(prompt);
List<ToolDefinition> toolDefs = extractToolDefinitions(prompt);
log.debug("ChatGPT stream: model={}, messages={}", model, messages.size());
return client.stream(model, messages, temp)
.map(delta -> {
Generation generation = new Generation(new AssistantMessage(delta),
ChatGenerationMetadata.builder().finishReason(null).build());
return new ChatResponse(List.of(generation),
ChatResponseMetadata.builder().model(model).build());
log.debug("[ChatGPT] stream: model={}, messages={}, tools={}", model, messages.size(), toolDefs.size());
// 状态累积 tool call arguments
Map<String, String> toolCallNames = new LinkedHashMap<>();
Map<String, StringBuilder> toolCallArgs = new LinkedHashMap<>();
return client.streamEvents(model, messages, temp, toolDefs)
.mapNotNull(event -> {
switch (event.type()) {
case "text" -> {
Generation gen = new Generation(new AssistantMessage(event.content()),
ChatGenerationMetadata.builder().finishReason(null).build());
return new ChatResponse(List.of(gen),
ChatResponseMetadata.builder().model(model).build());
}
case "tool_call_start" -> {
// 创建初始 ToolCall arguments NodeStreamingChatHelper 创建 accumulator
toolCallNames.put(event.toolCallId(), event.toolName());
toolCallArgs.put(event.toolCallId(), new StringBuilder());
List<AssistantMessage.ToolCall> startCalls = List.of(
new AssistantMessage.ToolCall(event.toolCallId(), "function", event.toolName(), "")
);
AssistantMessage startMsg = AssistantMessage.builder()
.content("")
.toolCalls(startCalls)
.build();
Generation startGen = new Generation(startMsg,
ChatGenerationMetadata.builder().finishReason(null).build());
return new ChatResponse(List.of(startGen),
ChatResponseMetadata.builder().model(model).build());
}
case "tool_call_args_delta" -> {
// 增量追加 arguments通过空 id ToolCall accumulator 追加
StringBuilder sb = toolCallArgs.get(event.toolCallId());
if (sb != null) sb.append(event.toolArgsDelta());
List<AssistantMessage.ToolCall> deltaCalls = List.of(
new AssistantMessage.ToolCall("", "function", "", event.toolArgsDelta())
);
AssistantMessage deltaMsg = AssistantMessage.builder()
.content("")
.toolCalls(deltaCalls)
.build();
Generation deltaGen = new Generation(deltaMsg,
ChatGenerationMetadata.builder().finishReason(null).build());
return new ChatResponse(List.of(deltaGen),
ChatResponseMetadata.builder().model(model).build());
}
case "tool_call_done" -> {
// 不需要再发一次完整的 accumulator 已经有了
return null;
}
case "done" -> {
// 流结束如果没有任何 tool call 产生过 done event 但有未完成的忽略
return null;
}
default -> { return null; }
}
});
}
@ -70,6 +126,36 @@ public class ChatGPTChatModel implements ChatModel {
.build();
}
/**
* Prompt ChatOptions 中提取 ToolDefinition 列表
*/
private List<ToolDefinition> extractToolDefinitions(Prompt prompt) {
ChatOptions options = prompt.getOptions();
if (options == null) return List.of();
// ToolCallingChatOptions OpenAiChatOptions 都可能包含 toolCallbacks
List<ToolCallback> callbacks = null;
if (options instanceof ToolCallingChatOptions tcOpts) {
callbacks = tcOpts.getToolCallbacks();
} else {
// 尝试反射获取Spring AI OpenAiChatOptions 也有 toolCallbacks
try {
var method = options.getClass().getMethod("getToolCallbacks");
@SuppressWarnings("unchecked")
var result = (List<ToolCallback>) method.invoke(options);
callbacks = result;
} catch (Exception ignored) {
// 不支持 tool callbacks
}
}
if (callbacks == null || callbacks.isEmpty()) return List.of();
return callbacks.stream()
.map(ToolCallback::getToolDefinition)
.toList();
}
private String resolveModel(Prompt prompt) {
if (prompt.getOptions() != null && prompt.getOptions().getModel() != null) {
return prompt.getOptions().getModel();

View File

@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.*;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
@ -15,10 +16,11 @@ import reactor.core.publisher.Flux;
import vip.mate.exception.MateClawException;
import vip.mate.llm.oauth.OpenAIOAuthService;
import java.util.List;
import java.util.*;
/**
* ChatGPT Backend API 客户端 调用 chatgpt.com/backend-api/codex/responsesResponses API 格式
* 支持 tool callingfunction_call
*/
@Slf4j
@Component
@ -33,25 +35,41 @@ public class ChatGPTResponsesClient {
private final WebClient webClient = WebClient.create();
/**
* 同步调用 ChatGPT Backend API 强制要求 stream=true
* 所以实际仍走 SSE只是收集完整响应后再返回
* 流式调用结果 包含文本增量和 tool call 事件
*/
public String call(String model, List<Message> messages, Double temperature) {
return stream(model, messages, temperature)
public record StreamEvent(String type, String content, String toolCallId, String toolName, String toolArgsDelta) {
public static StreamEvent text(String delta) { return new StreamEvent("text", delta, null, null, null); }
public static StreamEvent toolCallStart(String callId, String name) { return new StreamEvent("tool_call_start", null, callId, name, null); }
public static StreamEvent toolCallArgsDelta(String callId, String delta) { return new StreamEvent("tool_call_args_delta", null, callId, null, delta); }
public static StreamEvent toolCallDone(String callId, String args) { return new StreamEvent("tool_call_done", null, callId, null, args); }
public static StreamEvent done() { return new StreamEvent("done", null, null, null, null); }
}
/**
* 同步调用 收集完整响应仅文本部分
*/
public String call(String model, List<Message> messages, Double temperature, List<ToolDefinition> tools) {
return streamEvents(model, messages, temperature, tools)
.filter(e -> "text".equals(e.type()))
.map(StreamEvent::content)
.collectList()
.map(chunks -> String.join("", chunks))
.block();
}
/**
* 流式调用 Responses API (SSE)
* 流式调用 返回结构化事件文本 + tool_call
*/
public Flux<String> stream(String model, List<Message> messages, Double temperature) {
public Flux<StreamEvent> streamEvents(String model, List<Message> messages, Double temperature,
List<ToolDefinition> tools) {
String accessToken = oauthService.ensureValidAccessToken();
String accountId = oauthService.getAccountId();
ObjectNode requestBody = buildRequestBody(model, messages, temperature);
ObjectNode requestBody = buildRequestBody(model, messages, temperature, tools);
String bodyJson = requestBody.toString();
log.info("ChatGPT request body: {}", bodyJson);
log.info("[ChatGPT] Request: model={}, messages={}, tools={}", model, messages.size(),
tools != null ? tools.size() : 0);
log.debug("[ChatGPT] Request body: {}", bodyJson.length() > 2000
? bodyJson.substring(0, 2000) + "..." : bodyJson);
return webClient.post()
.uri(BASE_URL + RESPONSES_PATH)
@ -63,33 +81,42 @@ public class ChatGPTResponsesClient {
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
response -> response.bodyToMono(String.class)
.map(errorBody -> {
log.error("ChatGPT API error {}: {}", response.statusCode(), errorBody);
log.error("[ChatGPT] API error {}: {}", response.statusCode(), errorBody);
return new MateClawException("ChatGPT API " + response.statusCode() + ": " + errorBody);
}))
.bodyToFlux(String.class)
.doOnNext(raw -> log.debug("ChatGPT SSE raw: {}", raw.length() > 200 ? raw.substring(0, 200) + "..." : raw))
.doOnNext(raw -> log.debug("[ChatGPT] SSE raw: {}", raw.length() > 200 ? raw.substring(0, 200) + "..." : raw))
.filter(line -> !line.isBlank() && !line.equals("[DONE]"))
.filter(line -> line.startsWith("data:"))
.map(line -> {
// SSE 格式每行以 "data: " 开头需要去掉前缀
if (line.startsWith("data: ")) return line.substring(6);
if (line.startsWith("data:")) return line.substring(5);
return line;
return line.substring(5);
})
.filter(line -> !line.isBlank() && !line.equals("[DONE]"))
.mapNotNull(this::extractDeltaContent)
.mapNotNull(this::parseSSEEvent)
.onErrorMap(e -> e instanceof MateClawException ? e
: new MateClawException("ChatGPT 流式调用失败: " + e.getMessage()));
}
/**
* 纯文本流向后兼容
*/
public Flux<String> stream(String model, List<Message> messages, Double temperature) {
return streamEvents(model, messages, temperature, null)
.filter(e -> "text".equals(e.type()))
.map(StreamEvent::content);
}
// ==================== 请求构建 ====================
ObjectNode buildRequestBody(String model, List<Message> messages, Double temperature) {
ObjectNode buildRequestBody(String model, List<Message> messages, Double temperature,
List<ToolDefinition> tools) {
ObjectNode body = objectMapper.createObjectNode();
body.put("model", model);
body.put("stream", true); // ChatGPT Backend API 强制要求 stream=true
body.put("stream", true);
body.put("store", false);
// messages 中提取 system prompt instructions
// system prompt instructions
String systemPrompt = null;
for (Message msg : messages) {
if (msg.getMessageType() == MessageType.SYSTEM) {
@ -101,14 +128,13 @@ public class ChatGPTResponsesClient {
body.put("instructions", systemPrompt);
}
// system 消息 input 数组Responses API 格式
// 消息 input 数组Responses API 格式
ArrayNode input = objectMapper.createArrayNode();
int msgIndex = 0;
for (Message msg : messages) {
if (msg.getMessageType() == MessageType.SYSTEM) continue;
if (msg.getMessageType() == MessageType.USER) {
// User: content 必须是 [{ type: "input_text", text: "..." }] 格式
ObjectNode item = objectMapper.createObjectNode();
item.put("role", "user");
ArrayNode contentArr = objectMapper.createArrayNode();
@ -119,35 +145,92 @@ public class ChatGPTResponsesClient {
item.set("content", contentArr);
input.add(item);
} else if (msg.getMessageType() == MessageType.ASSISTANT) {
// Assistant: 转为 output message item
ObjectNode item = objectMapper.createObjectNode();
item.put("type", "message");
item.put("role", "assistant");
item.put("id", "msg_" + msgIndex);
ArrayNode contentArr = objectMapper.createArrayNode();
ObjectNode textPart = objectMapper.createObjectNode();
textPart.put("type", "output_text");
textPart.put("text", msg.getText() != null ? msg.getText() : "");
contentArr.add(textPart);
item.set("content", contentArr);
input.add(item);
AssistantMessage assistantMsg = (AssistantMessage) msg;
// 如果 assistant 消息包含 tool calls需要输出 function_call items
if (assistantMsg.hasToolCalls()) {
// 先输出文本部分如果有
String text = assistantMsg.getText();
if (text != null && !text.isBlank()) {
ObjectNode textItem = objectMapper.createObjectNode();
textItem.put("type", "message");
textItem.put("role", "assistant");
textItem.put("id", "msg_" + msgIndex);
ArrayNode contentArr = objectMapper.createArrayNode();
ObjectNode textPart = objectMapper.createObjectNode();
textPart.put("type", "output_text");
textPart.put("text", text);
contentArr.add(textPart);
textItem.set("content", contentArr);
input.add(textItem);
}
// 输出 function_call items
for (AssistantMessage.ToolCall tc : assistantMsg.getToolCalls()) {
ObjectNode fcItem = objectMapper.createObjectNode();
fcItem.put("type", "function_call");
fcItem.put("call_id", tc.id());
fcItem.put("name", tc.name());
fcItem.put("arguments", tc.arguments());
input.add(fcItem);
}
} else {
ObjectNode item = objectMapper.createObjectNode();
item.put("type", "message");
item.put("role", "assistant");
item.put("id", "msg_" + msgIndex);
ArrayNode contentArr = objectMapper.createArrayNode();
ObjectNode textPart = objectMapper.createObjectNode();
textPart.put("type", "output_text");
textPart.put("text", msg.getText() != null ? msg.getText() : "");
contentArr.add(textPart);
item.set("content", contentArr);
input.add(item);
}
} else if (msg.getMessageType() == MessageType.TOOL) {
// Tool result function_call_output
ToolResponseMessage toolMsg = (ToolResponseMessage) msg;
for (ToolResponseMessage.ToolResponse response : toolMsg.getResponses()) {
ObjectNode fcoItem = objectMapper.createObjectNode();
fcoItem.put("type", "function_call_output");
fcoItem.put("call_id", response.id());
fcoItem.put("output", response.responseData());
input.add(fcoItem);
}
}
msgIndex++;
}
body.set("input", input);
// 注意ChatGPT Backend API 的部分模型 gpt-5.4不支持 temperature
// 仅对非推理类旧模型 gpt-4o传递此参数
// temperature推理类模型不支持
if (temperature != null && !model.startsWith("gpt-5") && !model.startsWith("o")) {
body.put("temperature", temperature);
}
// tools 数组Responses API flat format
if (tools != null && !tools.isEmpty()) {
ArrayNode toolsArr = objectMapper.createArrayNode();
for (ToolDefinition tool : tools) {
ObjectNode toolNode = objectMapper.createObjectNode();
toolNode.put("type", "function");
toolNode.put("name", tool.name());
toolNode.put("description", tool.description());
try {
JsonNode params = objectMapper.readTree(tool.inputSchema());
toolNode.set("parameters", params);
} catch (Exception e) {
log.warn("[ChatGPT] Failed to parse tool schema for {}: {}", tool.name(), e.getMessage());
}
toolsArr.add(toolNode);
}
body.set("tools", toolsArr);
body.put("tool_choice", "auto");
}
// Responses API 特有参数
ObjectNode text = objectMapper.createObjectNode();
text.put("verbosity", "medium");
body.set("text", text);
// include reasoningOpenClaw 的标准参数
ArrayNode include = objectMapper.createArrayNode();
include.add("reasoning.encrypted_content");
body.set("include", include);
@ -158,28 +241,56 @@ public class ChatGPTResponsesClient {
// ==================== 响应解析 ====================
/**
* SSE delta 事件中提取增量文本
* 解析 SSE 事件 支持文本增量和 function_call 事件
*/
private String extractDeltaContent(String eventData) {
private StreamEvent parseSSEEvent(String eventData) {
try {
JsonNode node = objectMapper.readTree(eventData);
String type = node.path("type").asText("");
// response.output_text.delta 文本增量
// 文本增量
if ("response.output_text.delta".equals(type)) {
return node.path("delta").asText(null);
String delta = node.path("delta").asText(null);
return delta != null ? StreamEvent.text(delta) : null;
}
// response.completed / response.done 结束信号
// function_call 开始response.output_item.added with type=function_call
if ("response.output_item.added".equals(type)) {
JsonNode item = node.path("item");
if ("function_call".equals(item.path("type").asText(""))) {
String callId = item.path("call_id").asText("");
String name = item.path("name").asText("");
log.info("[ChatGPT] Tool call started: name={}, callId={}", name, callId);
return StreamEvent.toolCallStart(callId, name);
}
}
// function_call arguments 增量
if ("response.function_call_arguments.delta".equals(type)) {
String callId = node.path("call_id").asText("");
String delta = node.path("delta").asText("");
return StreamEvent.toolCallArgsDelta(callId, delta);
}
// function_call arguments 完成
if ("response.function_call_arguments.done".equals(type)) {
String callId = node.path("call_id").asText("");
String args = node.path("arguments").asText("{}");
log.info("[ChatGPT] Tool call done: callId={}, args={}", callId,
args.length() > 200 ? args.substring(0, 200) + "..." : args);
return StreamEvent.toolCallDone(callId, args);
}
// 完成/结束
if (type.startsWith("response.completed") || type.startsWith("response.done")) {
return null;
return StreamEvent.done();
}
// response.failed 错误
// 错误
if ("response.failed".equals(type)) {
String error = node.path("response").path("error").path("message").asText("Unknown error");
log.error("ChatGPT Responses API 返回错误: {}", error);
throw new MateClawException("ChatGPT <20><><EFBFBD>回错误: " + error);
log.error("[ChatGPT] Responses API error: {}", error);
throw new MateClawException("ChatGPT 回错误: " + error);
}
return null;

View File

@ -1568,7 +1568,7 @@ VALUES (
## 边界
-
-
- execute_shell_commandread_file
-
## 风格
@ -1715,7 +1715,7 @@ VALUES (
## 边界
-
-
- execute_shell_commandread_file
-
## 风格

View File

@ -1591,7 +1591,7 @@ VALUES (
## 边界
-
-
- execute_shell_commandread_file
-
## 风格
@ -1738,7 +1738,7 @@ VALUES (
## 边界
-
-
- execute_shell_commandread_file
-
## 风格

View File

@ -608,6 +608,18 @@ const segments = computed<MessageSegment[]>(() => {
}
}
// toolName + toolArgs tool_call segment
const seenToolCalls = new Set<string>()
const deduped = segs.filter(seg => {
if (seg.type !== 'tool_call') return true
const key = `${seg.toolName}::${seg.toolArgs || ''}`
if (seenToolCalls.has(key)) return false
seenToolCalls.add(key)
return true
})
segs.length = 0
segs.push(...deduped)
// thinking content content
// thinking 线
const thinkingIndices = segs

View File

@ -73,6 +73,8 @@ export interface UseChatReturn {
clearMessages: () => void
/** 重连到运行中的流 */
reconnectStream: (conversationId: string) => Promise<void>
/** 彻底重置流上下文 — 切换/新建会话时调用 */
resetForNewConversation: () => void
}
export interface SendMessageOptions {
@ -115,11 +117,23 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const segIdCounter = { value: 0 }
const genSegId = () => `seg-${Date.now()}-${segIdCounter.value++}`
/** 当前 turn 的唯一标识 — 确保 flushSegmentsToMessage 不会把旧 turn 的 segments 写到新消息 */
let activeTurnId = ''
/** 重置当前 turn 的流式状态 — 必须在每次创建新 assistant placeholder 之前调用 */
function resetCurrentTurnState() {
currentSegments.value = []
segIdCounter.value = 0
activeTurnId = `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
}
/** 将当前 segments 同步到助手消息的 metadata 中(实时渲染用) */
const flushSegmentsToMessage = () => {
if (!currentAssistantId.value || currentSegments.value.length === 0) return
const msg = getMessage(currentAssistantId.value)
if (!msg) return
// 保护:只写入当前 turn 创建的消息,避免旧 turn segments 污染新消息
if ((msg as any)._turnId && (msg as any)._turnId !== activeTurnId) return
const metadata = parseMetadata((msg as any).metadata)
updateMessage(currentAssistantId.value, {
...msg,
@ -257,14 +271,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
return
}
// 重置分段列表
currentSegments.value = []
segIdCounter.value = 0
const assistantMessage = createAssistantMessage('')
if (streamConversationId) {
assistantMessage.conversationId = streamConversationId
}
// 没有 placeholder 时才创建(正常路径 placeholder 已在 sendMessage 中创建)
resetCurrentTurnState()
const assistantMessage = createAssistantMessage('', streamConversationId)
;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string
})
@ -627,7 +637,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
}
if (!targetId) {
const placeholder = createAssistantMessage('')
resetCurrentTurnState()
const placeholder = createAssistantMessage('', streamConversationId)
;(placeholder as any)._turnId = activeTurnId
targetId = placeholder.id as string
currentAssistantId.value = targetId
}
@ -766,12 +778,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const queued = messageQueue.dequeue()
const messageContent = data.message || queued?.content || ''
if (messageContent) {
const userMessage = createUserMessage(messageContent, queued?.contentParts)
userMessage.conversationId = data.conversationId || streamConversationId
const convId = data.conversationId || streamConversationId
createUserMessage(messageContent, queued?.contentParts, convId)
}
// 2. 再创建 assistant 占位消息
const assistantMessage = createAssistantMessage('')
assistantMessage.conversationId = data.conversationId || streamConversationId
resetCurrentTurnState()
const convId2 = data.conversationId || streamConversationId
const assistantMessage = createAssistantMessage('', convId2)
;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string
streamPhase.value = 'thinking'
phaseInfo.value = null
@ -893,12 +907,12 @@ export function useChat(options: UseChatOptions): UseChatReturn {
try {
if (!isApprovalCommand) {
const userMessage = createUserMessage(content, contentParts)
userMessage.conversationId = conversationId
createUserMessage(content, contentParts, conversationId)
}
const assistantMessage = createAssistantMessage('')
assistantMessage.conversationId = conversationId
resetCurrentTurnState()
const assistantMessage = createAssistantMessage('', conversationId)
;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string
// contentParts 已由 buildOutgoingParts 包含 file entries不要重复合并 attachments
@ -949,10 +963,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} else {
// 没有活跃的流,直接发送
messageQueue.clear()
const userMessage = createUserMessage(content, options.contentParts)
userMessage.conversationId = conversationId
const assistantMessage = createAssistantMessage('')
assistantMessage.conversationId = conversationId
createUserMessage(content, options.contentParts, conversationId)
resetCurrentTurnState()
const assistantMessage = createAssistantMessage('', conversationId)
;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string
streamPhase.value = 'thinking'
phaseInfo.value = null
@ -969,8 +983,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// 回退为本地可见消息 + 清队列,避免消息静默丢失。
const failedQueued = messageQueue.dequeue()
if (failedQueued) {
const userMessage = createUserMessage(failedQueued.content, failedQueued.contentParts)
userMessage.conversationId = conversationId
createUserMessage(failedQueued.content, failedQueued.contentParts, conversationId)
}
error.value = new Error('Failed to queue message, please resend')
}
@ -983,6 +996,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// 这样 done 事件能正常到达onStreamEnd 被触发,消息状态和会话列表都能正确更新。
// 加一个 fallback timeout3 秒),防止 done 事件因网络问题永远不到达。
const stopGeneration = async () => {
// 在任何 await 之前冻结标识符 + 安装 fallback timer防止 resetForNewConversation 并发清空后丢失上下文
const convId = streamConversationId
const assistantId = currentAssistantId.value
// 先取消排队消息
messageQueue.clear()
@ -990,30 +1007,19 @@ export function useChat(options: UseChatOptions): UseChatReturn {
streamPhase.value = 'stopped'
phaseInfo.value = null
if (streamConversationId) {
try {
await fetchWithAuth(`${baseUrl}/api/v1/chat/${streamConversationId}/stop`, {
method: 'POST',
})
} catch (e) {
console.warn('[useChat] Stop API failed:', e)
}
}
// 不立即 disconnect —— 等 done 事件自然到达(后端 doOnCancel 会广播 done
// 设置 fallback timeout如果 3 秒内 done 事件没到达,强制清理
const convId = streamConversationId
const assistantId = currentAssistantId.value
// 在 await 之前安装 fallback timer确保即使 resetForNewConversation 并发执行也不会遗漏
if (stopFallbackTimer) clearTimeout(stopFallbackTimer)
stopFallbackTimer = setTimeout(() => {
stopFallbackTimer = null
console.warn('[useChat] Stop fallback: done event not received within 3s, force cleanup')
stream.disconnect()
// 只有当 stream 仍属于旧会话时才 disconnect防止误杀新会话的流
if (streamConversationId === convId || !streamConversationId) {
stream.disconnect()
}
if (currentAssistantId.value === assistantId && assistantId) {
setMessageStatus(assistantId, 'stopped')
currentAssistantId.value = null
}
// 强制触发 onStreamEnd 以刷新会话列表
onStreamEnd?.({
conversationId: convId,
reason: 'stopped',
@ -1029,6 +1035,15 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (stopFallbackTimer) { clearTimeout(stopFallbackTimer); stopFallbackTimer = null }
unsubscribeError()
})
// 发送后端 stop 请求fire-and-forget不阻塞 resetForNewConversation
if (convId) {
fetchWithAuth(`${baseUrl}/api/v1/chat/${convId}/stop`, {
method: 'POST',
}).catch(e => {
console.warn('[useChat] Stop API failed:', e)
})
}
}
// 取消排队消息
@ -1059,8 +1074,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
phaseInfo.value = null
// 创建 assistant 占位消息用于接收重连后的流数据
const assistantMessage = createAssistantMessage('')
assistantMessage.conversationId = conversationId
resetCurrentTurnState()
const assistantMessage = createAssistantMessage('', conversationId)
;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string
try {
@ -1111,6 +1127,23 @@ export function useChat(options: UseChatOptions): UseChatReturn {
})
}
/** 彻底重置流上下文 — 切换/新建会话时调用,确保旧流状态不污染新会话 */
const resetForNewConversation = () => {
stream.disconnect()
streamConversationId = ''
currentAssistantId.value = null
currentSegments.value = []
segIdCounter.value = 0
streamPhase.value = 'idle'
phaseInfo.value = null
error.value = null
messageQueue.clear()
if (stopFallbackTimer) {
clearTimeout(stopFallbackTimer)
stopFallbackTimer = null
}
}
return {
messages,
isGenerating,
@ -1128,6 +1161,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
addMessage,
clearMessages,
reconnectStream,
resetForNewConversation,
}
}

View File

@ -46,9 +46,9 @@ export interface UseMessagesReturn {
/** 获取消息 */
getMessage: (id: string | number) => Message | undefined
/** 创建用户消息 */
createUserMessage: (content: string, contentParts?: MessageContentPart[]) => Message
createUserMessage: (content: string, contentParts?: MessageContentPart[], conversationId?: string) => Message
/** 创建助手消息 */
createAssistantMessage: (content?: string) => Message
createAssistantMessage: (content?: string, conversationId?: string) => Message
/** 在消息列表头部插入更早的消息(分页加载) */
prependMessages: (olderMessages: Message[]) => void
/** 设置 hasMore 状态 */
@ -191,14 +191,14 @@ export function useMessages(options: UseMessagesOptions = {}): UseMessagesReturn
}
// 创建用户消息
const createUserMessage = (content: string, contentParts?: MessageContentPart[]): Message => {
const createUserMessage = (content: string, contentParts?: MessageContentPart[], conversationId?: string): Message => {
const parts: MessageContentPart[] = contentParts || [
{ type: 'text', text: content },
]
return addMessage({
role: 'user',
conversationId: '', // 由调用方设置
conversationId: conversationId || '',
content,
contentParts: parts,
status: 'completed',
@ -206,10 +206,10 @@ export function useMessages(options: UseMessagesOptions = {}): UseMessagesReturn
}
// 创建助手消息
const createAssistantMessage = (content: string = ''): Message => {
const createAssistantMessage = (content: string = '', conversationId?: string): Message => {
return addMessage({
role: 'assistant',
conversationId: '', // 由调用方设置
conversationId: conversationId || '',
content,
contentParts: content ? [{ type: 'text', text: content, visibleLength: 0 }] : [],
status: 'generating',

View File

@ -187,19 +187,17 @@ export function reconcileMessages(local: Message[], fetched: Message[]): Message
}
}
// 保留 fetched 中不存在的本地 assistant 消息(防止 lagging snapshot 丢弃刚完成的消息)
// 推断当前对话 ID取 fetched 中第一条消息的 conversationId
// 保留 fetched 中不存在的本地消息user + assistant防止 lagging snapshot 丢弃刚发送的消息
const fetchedConversationId = fetched.length > 0 ? (fetched[0] as any).conversationId : ''
for (const lm of local) {
const lid = String(lm.id)
if (!matchedLocalIds.has(lid) && lm.role === 'assistant') {
if (!matchedLocalIds.has(lid)) {
// 跳过不属于当前对话的本地消息,防止跨对话污染
// 无 conversationId 的 orphan 消息也不保留
const lmConvId = (lm as any).conversationId
if (!lmConvId || (fetchedConversationId && lmConvId !== fetchedConversationId)) {
continue
}
// 检查是否是 fetched 末尾之后的消息(刚完成DB 还没返回)
// 只保留在 fetched 末尾之后的消息(刚发送/刚完成DB 还没返回)
const lastFetchedTime = result.length > 0 ? result[result.length - 1].createTime : ''
if (!lastFetchedTime || (lm.createTime && lm.createTime >= lastFetchedTime)) {
result.push(lm)

View File

@ -563,6 +563,7 @@ const {
stopGeneration: stopChatGeneration,
cancelQueued,
reconnectStream: reconnectChatStream,
resetForNewConversation,
} = useChat({
baseUrl: '',
onStreamEnd: async (meta) => {
@ -770,18 +771,19 @@ async function loadConversations() {
async function refreshCurrentConversationMessages(conversationId: string) {
if (!conversationId) return
// stop
if (isGenerating.value) return
// DB thinking + text
if (streamPhase.value === 'awaiting_approval') return
try {
const res: any = await conversationApi.listMessages(conversationId)
// Stale guardawait
if (currentConversationId.value !== conversationId) return
// isGenerating await
if (isGenerating.value) return
const fetched = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
//
// conversationId orphan conversationId
const currentMessages = messages.value.filter(
(m: any) => !m.conversationId || m.conversationId === conversationId
(m: any) => m.conversationId === conversationId
)
// reconcile poorer DB local rich message
messages.value = reconcileMessages(currentMessages, fetched)
} catch (e) {
console.warn('[ChatConsole] Failed to refresh current conversation messages:', e)
@ -806,13 +808,15 @@ async function hydrateStateFromRoute() {
messages.value = []
try {
const res: any = await conversationApi.listMessages(conversationId)
if (currentConversationId.value !== conversationId) return
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
} catch {
//
}
try {
if (currentConversationId.value !== conversationId) return
const statusRes: any = await conversationApi.getStatus(conversationId)
if (statusRes.data?.streamStatus === 'running') {
if (currentConversationId.value === conversationId && statusRes.data?.streamStatus === 'running') {
await reconnectStream(conversationId)
}
} catch {
@ -839,16 +843,19 @@ async function selectConversation(conv: Conversation) {
resetStreamingState()
currentConversationId.value = conv.conversationId
selectedAgentId.value = conv.agentId || selectedAgentId.value
const requestedConvId = conv.conversationId
try {
const res: any = await conversationApi.listMessages(conv.conversationId)
const res: any = await conversationApi.listMessages(requestedConvId)
// Stale guardawait
if (currentConversationId.value !== requestedConvId) return
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
// Hydrate pending approvals
try {
const approvalRes: any = await chatApi.getPendingApprovals(conv.conversationId)
const approvalRes: any = await chatApi.getPendingApprovals(requestedConvId)
if (currentConversationId.value !== requestedConvId) return
const pendingApprovals = approvalRes.data || []
if (pendingApprovals.length > 0) {
// pending approvals assistant
const assistantMessages = messages.value.filter(m => m.role === 'assistant')
const lastAssistant = assistantMessages[assistantMessages.length - 1]
if (lastAssistant) {
@ -862,7 +869,6 @@ async function selectConversation(conv: Conversation) {
arguments: pa.toolArguments,
reason: pa.reason,
status: 'pending_approval',
// Phase 6:
findings: pa.findingsJson ? JSON.parse(pa.findingsJson) : undefined,
maxSeverity: pa.maxSeverity || undefined,
summary: pa.summary || undefined,
@ -875,8 +881,8 @@ async function selectConversation(conv: Conversation) {
// hydration 使
}
if (conv.streamStatus === 'running') {
await reconnectStream(conv.conversationId)
if (currentConversationId.value === requestedConvId && conv.streamStatus === 'running') {
await reconnectStream(requestedConvId)
}
} catch (e) {
ElMessage.error(t('chat.loadMessagesFailed'))
@ -1126,7 +1132,9 @@ function handleCancelQueued() {
//
function resetStreamingState() {
// fire-and-forget
stopChatGeneration()
resetForNewConversation()
}
// ============ ============

77
text.txt Normal file
View File

@ -0,0 +1,77 @@
请基于以下已确认事实,分析并修复前端 Chat 会话串线问题。不要泛泛而谈,直接围绕时序、状态隔离、消息归属和可验证修复方案展开。
问题背景:
用户连续两次问了同一句话“你有记忆里有啥”,系统创建了两个不同的 conversationId并且后端日志显示这两个会话都是独立、正常完成的
1. 第一次会话:
- conversationId: conv_1775859743291_z9pav7
- SSE chat 建立时间2026-04-11 06:22:32
- user message 已落库
- assistant message 已落库
- done 已发送
- stream fully completed
2. 第二次会话:
- conversationId: conv_1775859773188_335bjk
- SSE chat 建立时间2026-04-11 06:22:55
- user message 已落库
- assistant message 已落库
- done 已发送
- stream fully completed
关键信号:
- 两次请求是两个不同 conversationId。
- 服务端日志没有显示 approval / awaiting_approval / interrupt / queued_input 相关链路。
- 第一个会话已经完成后,前端仍然发了一次 stop请求日志为 `stopped=false`,这说明 stop 到达时旧流已经结束,不是服务端还在跑旧流。
- 因此,这更像是前端本地状态污染、会话切换时序竞争、或 reconcile 逻辑把旧本地消息错误带入新会话,而不是后端把旧会话内容串到了新会话。
当前高优先级怀疑点:
1. 切换会话 / 新建会话时,只调用了 stopChatGeneration(),但没有等待旧 SSE 流和本地状态完全清理。
- 这会导致旧流晚到的事件delta / done / error在新会话已经创建 assistant 占位消息后,继续命中新会话的共享状态。
- useChat 内部当前使用共享的 `currentAssistantId`、`streamConversationId`、`messages`,如果不做 conversation 级别隔离,就有天然串线风险。
2. 审批占位 assistant message 在某些路径下创建后没有 conversationId。
- 当前 refresh / reconcile 的过滤逻辑对 `!conversationId` 的本地消息仍可能放行。
- 这类 orphan message 可能被错误并入后续任意会话。
3. reconcileMessages() 当前有“保留 fetched 中不存在的本地 assistant 消息”的策略。
- 这个策略本来是为了防止 lagging snapshot 丢刚完成的 rich message。
- 但如果 local 里混入了旧会话消息、orphan message、或者未彻底清理的占位消息就会把错误消息保留下来。
你的任务:
1. 先明确判断:
- 根因是否主要在前端状态管理,而非后端 conversation/message 落库。
- 哪一条最可能导致“上一轮消息出现在新会话”。
2. 给出修复方案,要求具体到代码层面:
- 会话切换 / 新建会话时,如何确保旧流彻底解绑。
- 如何避免旧流事件写入当前会话。
- `currentAssistantId` / `streamConversationId` 是否应该按 conversation 隔离,还是至少在事件处理时校验 conversationId。
- 所有本地新建 message 是否必须强制携带 conversationId。
- reconcileMessages() 是否应该完全禁止保留非当前 conversation 的本地消息。
- 对 `conversationId` 为空的本地消息,应该如何处理。
3. 给出建议的防御性约束:
- 每个 SSE 事件落地前必须校验所属 conversationId。
- onStreamEnd / reconnect / refreshCurrentConversationMessages 只能作用于当前会话。
- 新会话开始前,旧会话的 placeholder / generating message 必须被清理或隔离。
4. 输出格式要求:
- 先给“根因判断”。
- 再给“最小修复方案”。
- 再给“更稳妥的长期方案”。
- 最后给“如何验证修复有效”,至少覆盖:
- 连续快速新建会话并发送相同问题
- 旧会话刚结束时立刻切新会话
- refreshCurrentConversationMessages 在流结束后执行
- orphan assistant message / 空 conversationId message 不得污染新会话
补充要求:
- 不要只说“加锁”或“避免 race condition”要明确到状态变量、事件处理器、过滤条件和消息生命周期。
- 如果你认为某个现有修补不够,请直接指出为什么不够。
- 如果需要改 reconcileMessages请说明保留本地 assistant message 的边界条件。