| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611 |
- package com.travel.intellitravel_pioneer.service.ai;
- import com.fasterxml.jackson.core.type.TypeReference;
- import com.fasterxml.jackson.databind.JsonNode;
- import com.fasterxml.jackson.databind.ObjectMapper;
- import com.fasterxml.jackson.databind.node.ArrayNode;
- import com.fasterxml.jackson.databind.node.ObjectNode;
- import com.travel.intellitravel_pioneer.exception.IntellitravelException;
- import com.travel.intellitravel_pioneer.vo.itinerary.ItineraryPlan;
- import com.travel.intellitravel_pioneer.vo.itinerary.ItineraryRequest;
- import org.springframework.beans.factory.annotation.Value;
- import org.springframework.http.MediaType;
- import org.springframework.stereotype.Component;
- import org.springframework.web.client.RestClient;
- import java.net.URI;
- import java.net.http.HttpClient;
- import java.net.http.HttpRequest;
- import java.net.http.HttpResponse;
- import java.util.ArrayList;
- import java.util.HashMap;
- import java.util.List;
- import java.util.Map;
- import java.util.concurrent.CompletableFuture;
- import java.util.concurrent.TimeUnit;
- @Component
- public class HttpTripPlanningAiClient implements TripPlanningAiClient {
- private final ObjectMapper objectMapper;
- @Value("${trip.ai.provider:llm}")
- private String aiProvider;
- @Value("${trip.ai.base-url:}")
- private String aiBaseUrl;
- @Value("${trip.ai.plan-path:/api/trips/plan}")
- private String aiPlanPath;
- @Value("${trip.ai.status-analysis-path:/api/trips/status/analyze}")
- private String aiStatusAnalysisPath;
- @Value("${trip.ai.llm.base-url:https://api.siliconflow.cn}")
- private String llmBaseUrl;
- @Value("${trip.ai.llm.chat-path:/v1/chat/completions}")
- private String llmChatPath;
- @Value("${trip.ai.llm.api-key:}")
- private String llmApiKey;
- @Value("${trip.ai.llm.model:Qwen/Qwen3-8B}")
- private String llmModel;
- @Value("${trip.ai.llm.temperature:0.2}")
- private Double llmTemperature;
- public HttpTripPlanningAiClient(ObjectMapper objectMapper) {
- this.objectMapper = objectMapper;
- }
- @Override
- public ItineraryPlan generateItinerary(String token, ItineraryRequest request) {
- return generateItinerary(token, request, null);
- }
- @Override
- public ItineraryPlan generateItinerary(String token, ItineraryRequest request,
- java.util.function.Consumer<String> toolNotifier) {
- if (isLlmProvider()) {
- return callLlmForItinerary(request, toolNotifier);
- }
- Map<String, Object> requestBody = new HashMap<>();
- requestBody.put("request", request);
- String rawResponse = callCustomAiService(aiPlanPath, token, requestBody);
- return parseItinerary(rawResponse);
- }
-
- private String callCustomAiService(String path, String token, Object requestBody) {
- if (aiBaseUrl == null || aiBaseUrl.isBlank()) {
- throw new IntellitravelException("未配置 AI 规划服务");
- }
- int maxRetries = 3;
- int attempt = 0;
- long backoff = 1000;
- Exception lastException = null;
- while (attempt < maxRetries) {
- try {
- return RestClient.create()
- .post()
- .uri(aiBaseUrl + path)
- .contentType(MediaType.APPLICATION_JSON)
- .headers(headers -> {
- if (token != null && !token.isBlank()) {
- headers.set("token", token);
- }
- })
- .body(requestBody)
- .retrieve()
- .body(String.class);
- } catch (Exception e) {
- lastException = e;
- attempt++;
- if (attempt >= maxRetries)
- break;
- try {
- Thread.sleep(backoff);
- backoff *= 2;
- } catch (InterruptedException ie) {
- Thread.currentThread().interrupt();
- throw new IntellitravelException("AI 调用被迫中断: " + ie.getMessage());
- }
- }
- }
- throw new IntellitravelException("AI 服务当前比较繁忙,请稍等片刻再试哦~");
- }
- private boolean isLlmProvider() {
- return "llm".equalsIgnoreCase(aiProvider);
- }
- private ItineraryPlan callLlmForItinerary(ItineraryRequest request,
- java.util.function.Consumer<String> toolNotifier) {
- // 设置环境变量 AMAP_MCP_KEY (申请: https://lbs.amap.com/dev/key/app)
- String amapKey = System.getenv().getOrDefault("AMAP_MCP_KEY", "");
- String amapUrl = "https://mcp.amap.com/sse?key=" + amapKey;
- try (AmapMcpClient mcpClient = new AmapMcpClient(amapUrl)) {
- mcpClient.initialize();
- JsonNode tools = mcpClient.listTools();
- List<Map<String, Object>> messages = new ArrayList<>();
- // System prompt
- String systemInstructions = "你是一位专业的旅行规划助手。请根据用户请求,生成一个详细的行程计划。\n" +
- "【工作流程与思考机制】\n" +
- "1. 在调用任何工具之前,你必须先输出一个【思考过程】(Thought),简要分析当前需求,并解释为何需要调用该工具(例如验证天气、查询票价等)。\n" +
- " 格式示例:\n" +
- " Thought: 用户想去北京,我需要先查询北京的天气情况来决定行程。\n" +
- " (随后发起工具调用)\n" +
- "2. 必须使用提供的工具查询和验证每一个景点的真实性和经纬度。严禁伪造、猜测或生成不存在的地点名称和坐标。只有在工具返回结果中真实存在的地点,才能加入最终的行程计划。\n" +
- "3. 如果工具调用失败或未找到信息,请在思考过程中说明情况,并尝试更换关键词重新搜索,绝不能凭空捏造地点。\n" +
- "\n" +
- "【最终交付物】\n" +
- "当所有规划完成且不再调用工具时,请直接且仅输出最终的行程计划JSON。\n" +
- "注意:\n" +
- "1. 最终输出必须是纯粹合法的JSON字符串,严禁在JSON文本内部或周围夹杂Markdown代码块(如```json)、【思考过程】或任何其他解释性文字。\n" +
- "2. 必须确保JSON格式完全合法。特别注意:所有的字符串值如果包含双引号必须进行转义,各个属性之间必须有逗号分隔!\n" +
- "3. 规划中禁止使用泛指词汇,每一项具体行程必须为具体且真实存在的地点,严禁出现“当地特色餐厅”、“网红小吃店”这类模糊描述,并且必须提供真实准确的经纬度。\n" +
- "4. 每天的行程项不得少于三项,最终规划中不得有重复的行程节点。\n" +
- "JSON结构如下:\n" +
- "{\n" +
- " \"title\": \"整个行程的标题,如:北京三日游\",\n" +
- " \"estimatedCost\": 1500.0, // 预算总花费\n" +
- " \"totalDays\": 3, // 总天数\n" +
- " \"dailyPlans\": [\n" +
- " {\n" +
- " \"dayNumber\": 1, // 第几天,依次为1,2,3...\n" +
- " \"items\": [\n" +
- " {\n" +
- " \"type\": \"景点|餐饮|住宿|购物\", // 只能填这4个中的1个\n" +
- " \"name\": \"具体的地点名称,如:故宫博物院\",\n" +
- " \"description\": \"该行程节点的详细描述,如游玩时间,门票,交通建议等\",\n" +
- " \"location\": {\n" +
- " \"lat\": 39.9163, // 必须是 Double 类型的真实纬度\n" +
- " \"lng\": 116.3971, // 必须是 Double 类型的真实经度\n" +
- " \"name\": \"位置名称\"\n" +
- " }\n" +
- " }\n" +
- " ]\n" +
- " }\n" +
- " ]\n" +
- "}";
- messages.add(Map.of("role", "system", "content", systemInstructions));
- Map<String, Object> input = new HashMap<>();
- input.put("request", request);
- messages.add(Map.of("role", "user", "content", toJsonString(input)));
- // Agent Loop
- int maxTurns = 30; // 增加最大轮数以支持更多的搜索和验证
- for (int i = 0; i < maxTurns; i++) {
- System.out.println("Agent turn " + (i + 1));
- JsonNode response = callLlmChat(messages, tools);
- JsonNode choices = response.path("choices");
- if (choices.isEmpty()) {
- throw new IntellitravelException("AI 没有返回任何内容,请稍候再试呀~");
- }
- JsonNode message = choices.get(0).path("message");
- Map<String, Object> assistantMsg = new HashMap<>();
- assistantMsg.put("role", message.path("role").asText());
- if (message.has("content") && !message.get("content").isNull()) {
- assistantMsg.put("content", message.get("content").asText());
- }
- if (message.has("tool_calls")) {
- assistantMsg.put("tool_calls", message.get("tool_calls"));
- }
- messages.add(assistantMsg);
- if (message.has("tool_calls")) {
- JsonNode toolCalls = message.get("tool_calls");
- for (JsonNode toolCall : toolCalls) {
- String id = toolCall.path("id").asText();
- JsonNode functionNode = toolCall.path("function");
- String name = functionNode.path("name").asText();
- String args = functionNode.path("arguments").asText("{}");
- System.out.println("Invoking tool: " + name);
- if (toolNotifier != null) {
- try {
- String cleanArgs = args.replaceAll("\\s+", " ").trim();
- toolNotifier.accept("Invoking tool: " + name + " with args: " + cleanArgs);
- } catch (Exception e) {
- System.err.println("Tool notifier error: " + e.getMessage());
- }
- }
- String result;
- try {
- if (name.isEmpty()) {
- result = "Error: Tool name is missing";
- } else {
- result = mcpClient.callTool(name, args);
- // 防止工具返回结果过长导致Token超出限制
- if (result != null && result.length() > 5000) {
- result = result.substring(0, 5000) + "... [Result truncated due to length]";
- }
- }
- } catch (Exception e) {
- result = "Error executing tool " + name + ": " + e.toString();
- }
- if (toolNotifier != null) {
- try {
- toolNotifier.accept("Tool " + name + " completed");
- } catch (Exception e) {
- System.err.println("Tool notifier error: " + e.getMessage());
- }
- }
- Map<String, Object> toolMsg = new HashMap<>();
- toolMsg.put("role", "tool");
- toolMsg.put("tool_call_id", id);
- toolMsg.put("name", name);
- toolMsg.put("content", result);
- messages.add(toolMsg);
- }
- } else {
- // Final answer found
- String content = message.path("content").asText();
- ItineraryPlan plan = parseItinerary(content);
- if (plan != null && plan.getDestination() == null && request.getDestination() != null) {
- plan.setDestination(request.getDestination());
- }
- return plan;
- }
- }
- throw new IntellitravelException("AI 认真思考了太久,暂时无法完成复杂的规划,请换个简单点的要求试试吧~");
- } catch (IntellitravelException ie) {
- throw ie;
- } catch (Exception e) {
- e.printStackTrace();
- throw new IntellitravelException("啊哦,生成行程时遇到了小阻碍(网络不稳定或服务器繁忙),请稍后重试哦~");
- }
- }
- private String toJsonString(Object value) {
- try {
- return objectMapper.writeValueAsString(value);
- } catch (Exception e) {
- throw new IntellitravelException("Failed to build GPT request: " + e.getMessage());
- }
- }
- private String stripCodeFence(String text) {
- if (text == null) {
- return null;
- }
- String trimmed = text.trim();
- if (trimmed.startsWith("```") && trimmed.endsWith("```")) {
- int firstLineBreak = trimmed.indexOf('\n');
- if (firstLineBreak > 0) {
- trimmed = trimmed.substring(firstLineBreak + 1, trimmed.length() - 3).trim();
- }
- }
- return trimmed;
- }
- private ItineraryPlan parseItinerary(String rawResponse) {
- String cleaned = stripCodeFence(rawResponse);
- String jsonToParse = cleaned;
- // 尝试提取最外层的JSON对象(应对包含思考过程或Markdown标记的情况)
- int firstBrace = cleaned.indexOf('{');
- int lastBrace = cleaned.lastIndexOf('}');
- if (firstBrace >= 0 && lastBrace >= 0 && lastBrace > firstBrace) {
- jsonToParse = cleaned.substring(firstBrace, lastBrace + 1);
- }
- try {
- JsonNode root = objectMapper.readTree(jsonToParse);
- // 直接尝试解析为ItineraryPlan(期望根节点就是行程计划对象)
- ItineraryPlan plan = objectMapper.treeToValue(root, ItineraryPlan.class);
- return plan;
- } catch (Exception e) {
- // 如果直接解析失败,可以尝试回退到旧格式(兼容性处理)
- try {
- JsonNode fallbackRoot = objectMapper.readTree(jsonToParse);
- JsonNode itineraryNode = null;
- if (fallbackRoot.has("data") && fallbackRoot.get("data") != null
- && fallbackRoot.get("data").has("itinerary")) {
- itineraryNode = fallbackRoot.get("data").get("itinerary");
- } else if (fallbackRoot.has("itinerary")) {
- itineraryNode = fallbackRoot.get("itinerary");
- }
- if (itineraryNode != null && !itineraryNode.isNull()) {
- ItineraryPlan plan = objectMapper.treeToValue(itineraryNode, ItineraryPlan.class);
- return plan;
- }
- throw new IntellitravelException("AI 未能返回符合规范的行程,请再次尝试哦~");
- } catch (Exception ex) {
- throw new IntellitravelException("AI 生成的排版有点小偏差,请稍后刷新重试呀~");
- }
- }
- }
- private JsonNode callLlmChat(List<Map<String, Object>> messages, JsonNode tools) {
- ObjectNode body = objectMapper.createObjectNode();
- body.put("model", llmModel);
- body.put("temperature", llmTemperature);
- body.set("messages", objectMapper.valueToTree(messages));
- if (tools != null && !tools.isEmpty()) {
- body.set("tools", tools);
- }
- String jsonBody;
- try {
- jsonBody = objectMapper.writeValueAsString(body);
- } catch (Exception e) {
- throw new IntellitravelException("准备 AI 请求数据失败,请重试~");
- }
- String raw = executeGptRequest(jsonBody);
- try {
- return objectMapper.readTree(raw);
- } catch (Exception e) {
- throw new IntellitravelException("解读 AI 响应数据出现小问题,请再试一次哦~");
- }
- }
- private String executeGptRequest(Object body) {
- if (llmApiKey == null || llmApiKey.isBlank()) {
- throw new IntellitravelException("未配置 AI 密钥,请联系系统管理员");
- }
- int maxRetries = 3;
- int attempt = 0;
- long backoff = 1000;
- Exception lastException = null;
- while (attempt < maxRetries) {
- try {
- return RestClient.create()
- .post()
- .uri(llmBaseUrl + llmChatPath)
- .contentType(MediaType.APPLICATION_JSON)
- .headers(headers -> headers.setBearerAuth(llmApiKey))
- .body(body)
- .retrieve()
- .body(String.class);
- } catch (Exception e) {
- lastException = e;
- attempt++;
- if (attempt >= maxRetries)
- break;
- try {
- Thread.sleep(backoff);
- backoff *= 2;
- } catch (InterruptedException ie) {
- Thread.currentThread().interrupt();
- throw new IntellitravelException("AI 调用被意外中断: " + ie.getMessage());
- }
- }
- }
- throw new IntellitravelException("AI 规划师服务器当前可能比较繁忙或处于维护中,请稍等片刻再试哦~");
- }
- private class AmapMcpClient implements AutoCloseable {
- private final String sseUrl;
- private final HttpClient client;
- private String postEndpoint;
- private CompletableFuture<Void> sseTask;
- private int requestId = 0;
- private final java.util.concurrent.ConcurrentMap<Integer, CompletableFuture<JsonNode>> pendingRequests = new java.util.concurrent.ConcurrentHashMap<>();
- public AmapMcpClient(String sseUrl) {
- this.sseUrl = sseUrl;
- this.client = HttpClient.newBuilder().executor(java.util.concurrent.Executors.newCachedThreadPool())
- .build();
- }
- public void initialize() throws Exception {
- // 1. Connect to SSE and find endpoint
- URI uri = URI.create(sseUrl);
- HttpRequest request = HttpRequest.newBuilder().uri(uri).GET().build();
- CompletableFuture<String> endpointFuture = new CompletableFuture<>();
- sseTask = client.sendAsync(request, HttpResponse.BodyHandlers.ofLines())
- .whenComplete((r, e) -> {
- if (e != null) {
- endpointFuture.completeExceptionally(e);
- }
- })
- .thenAccept(response -> {
- if (response.statusCode() != 200) {
- endpointFuture.completeExceptionally(new RuntimeException(
- "SSE Connection failed with status: " + response.statusCode()));
- return;
- }
- java.util.concurrent.atomic.AtomicReference<String> currentEvent = new java.util.concurrent.atomic.AtomicReference<>(
- "");
- java.util.concurrent.atomic.AtomicReference<StringBuilder> currentData = new java.util.concurrent.atomic.AtomicReference<>(
- new StringBuilder());
- response.body().forEach(line -> {
- String trimmedLine = line.trim();
- if (trimmedLine.isEmpty()) {
- // Dispatch event
- String event = currentEvent.get();
- String data = currentData.get().toString();
- if ("endpoint".equals(event)) {
- if (!endpointFuture.isDone() && !data.isBlank()) {
- endpointFuture.complete(data.trim());
- }
- } else if ("message".equals(event)) {
- try {
- JsonNode msg = objectMapper.readTree(data);
- if (msg.has("id")) {
- int id = msg.get("id").asInt();
- CompletableFuture<JsonNode> future = pendingRequests.remove(id);
- if (future != null) {
- if (msg.has("error")) {
- // Convert MCP error to Exception if easier, or just pass the full
- // node
- // Here we pass the full node and let caller check "error"
- future.complete(msg);
- } else {
- future.complete(msg);
- }
- }
- }
- } catch (Exception e) {
- System.err.println("Failed to parse SSE message: " + e.getMessage());
- }
- }
- // Reset
- currentEvent.set("");
- currentData.set(new StringBuilder());
- } else if (trimmedLine.startsWith("event:")) {
- currentEvent.set(trimmedLine.substring(6).trim());
- } else if (trimmedLine.startsWith("data:")) {
- currentData.get().append(trimmedLine.substring(5).trim()).append("\n");
- }
- });
- });
- String relativeEndpoint = endpointFuture.get(120, TimeUnit.SECONDS);
- // Append query parameters from original URL if not present in the new endpoint?
- // Usually the session ID is enough, but as seen in testing, original key might
- // be irrelevant if session is active.
- // But let's just resolve.
- this.postEndpoint = uri.resolve(relativeEndpoint).toString();
- // 2. Initialize MCP session
- JsonNode initResponse = callJsonRpc("initialize", Map.of(
- "protocolVersion", "2024-11-05",
- "capabilities", Map.of("sampling", Map.of()),
- "clientInfo", Map.of("name", "java-client", "version", "1.0")));
- if (initResponse.has("error")) {
- throw new RuntimeException(
- "MCP Initialization failed: " + initResponse.get("error").path("message").asText());
- }
- sendJsonRpcNotification("notifications/initialized", Map.of());
- }
- public JsonNode listTools() throws Exception {
- JsonNode response = callJsonRpc("tools/list", Map.of());
- if (response.has("error")) {
- System.err.println("Error listing tools: " + response.get("error").toPrettyString());
- return objectMapper.createArrayNode();
- }
- JsonNode tools = response.path("result").path("tools");
- if (tools.isMissingNode()) {
- return objectMapper.createArrayNode();
- }
- // Convert to OpenAI Tool format
- ArrayNode llmTools = objectMapper.createArrayNode();
- if (tools.isArray()) {
- for (JsonNode tool : tools) {
- ObjectNode llmTool = objectMapper.createObjectNode();
- llmTool.put("type", "function");
- ObjectNode function = llmTool.putObject("function");
- function.put("name", tool.path("name").asText());
- function.put("description", tool.path("description").asText());
- function.set("parameters", tool.path("inputSchema"));
- llmTools.add(llmTool);
- }
- }
- return llmTools;
- }
- public String callTool(String name, String argumentsJson) throws Exception {
- Map<String, Object> args = objectMapper.readValue(argumentsJson, new TypeReference<Map<String, Object>>() {
- });
- JsonNode response = callJsonRpc("tools/call", Map.of(
- "name", name,
- "arguments", args));
- if (response.has("error")) {
- return "Error: " + response.get("error").path("message").asText();
- }
- JsonNode content = response.path("result").path("content");
- List<String> texts = new ArrayList<>();
- if (content.isArray()) {
- for (JsonNode item : content) {
- if ("text".equals(item.path("type").asText())) {
- texts.add(item.path("text").asText());
- }
- }
- }
- return String.join("\n", texts);
- }
- private JsonNode callJsonRpc(String method, Object params) throws Exception {
- int id;
- synchronized (this) {
- id = requestId++;
- }
- CompletableFuture<JsonNode> future = new CompletableFuture<>();
- pendingRequests.put(id, future);
- try {
- Map<String, Object> request = new HashMap<>();
- request.put("jsonrpc", "2.0");
- request.put("id", id);
- request.put("method", method);
- request.put("params", params);
- HttpResponse<String> response = sendRpcHttpRequest(request);
- if (response.statusCode() >= 400) {
- throw new RuntimeException("MCP RPC failed: " + response.body());
- }
- // Wait for response from SSE
- return future.get(30, TimeUnit.SECONDS);
- } catch (Exception e) {
- pendingRequests.remove(id);
- throw e;
- }
- }
- private void sendJsonRpcNotification(String method, Object params) throws Exception {
- Map<String, Object> request = new HashMap<>();
- request.put("jsonrpc", "2.0");
- request.put("method", method);
- request.put("params", params);
- HttpResponse<String> response = sendRpcHttpRequest(request);
- if (response.statusCode() >= 400) {
- throw new RuntimeException("MCP Notification failed: " + response.body());
- }
- }
- private HttpResponse<String> sendRpcHttpRequest(Map<String, Object> request) throws Exception {
- String requestBody = objectMapper.writeValueAsString(request);
- HttpRequest postReq = HttpRequest.newBuilder()
- .uri(URI.create(postEndpoint))
- .header("Content-Type", "application/json")
- .POST(HttpRequest.BodyPublishers.ofString(requestBody))
- .build();
- return client.send(postReq, HttpResponse.BodyHandlers.ofString());
- }
- @Override
- public void close() {
- if (sseTask != null) {
- sseTask.cancel(true);
- }
- }
- }
- }
|