| src/main/java/com/zy/ai/service/LlmChatService.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/java/com/zy/ai/service/LlmRoutingService.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/java/com/zy/ai/service/LlmSpringAiClientService.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/resources/application.yml | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/main/webapp/views/ai/diagnosis.html | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
src/main/java/com/zy/ai/service/LlmChatService.java
@@ -1,22 +1,15 @@ package com.zy.ai.service; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.zy.ai.entity.ChatCompletionRequest; import com.zy.ai.entity.ChatCompletionResponse; import com.zy.ai.entity.LlmCallLog; import com.zy.ai.entity.LlmRouteConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClientResponseException; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Flux; @@ -25,7 +18,6 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.UUID; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -41,6 +33,7 @@ private final LlmRoutingService llmRoutingService; private final LlmCallLogService llmCallLogService; private final LlmSpringAiClientService llmSpringAiClientService; @Value("${llm.base-url:}") private String fallbackBaseUrl; @@ -264,45 +257,11 @@ drain.setDaemon(true); drain.start(); boolean springAiStreaming = canUseSpringAi(routeReq); Flux<String> streamSource = springAiStreaming ? streamFluxWithSpringAi(route, routeReq) : streamFlux(route, routeReq); Flux<String> streamSource = streamFluxWithSpringAi(route, routeReq); streamSource.subscribe(payload -> { if (payload == null || payload.isEmpty()) return; if (springAiStreaming) { queue.offer(payload); appendLimited(outputBuffer, payload); return; } String[] events = payload.split("\\r?\\n\\r?\\n"); for (String part : events) { String s = part; if (s == null || s.isEmpty()) continue; if (s.startsWith("data:")) { s = s.substring(5); if (s.startsWith(" ")) s = s.substring(1); } if ("[DONE]".equals(s.trim())) { doneSeen.set(true); continue; } try { JSONObject obj = JSON.parseObject(s); JSONArray choices = obj.getJSONArray("choices"); if (choices != null && !choices.isEmpty()) { JSONObject c0 = choices.getJSONObject(0); JSONObject delta = c0.getJSONObject("delta"); if (delta != null) { String content = delta.getString("content"); if (content != null) { queue.offer(content); appendLimited(outputBuffer, content); } } } } catch (Exception e) { log.warn("解析 LLM stream 片段失败: {}", e.getMessage()); } } queue.offer(payload); appendLimited(outputBuffer, payload); }, err -> { errorSeen.set(true); doneSeen.set(true); @@ -319,100 +278,27 @@ } if (onError != null) onError.accept(err); }, () -> { if (!doneSeen.get()) { RuntimeException ex = new RuntimeException("LLM 流意外完成"); errorSeen.set(true); doneSeen.set(true); boolean canSwitch = shouldSwitch(route, false); markFailure(route, ex, canSwitch); recordCall(traceId, scene, true, index + 1, route, false, 200, System.currentTimeMillis() - start, routeReq, outputBuffer.toString(), "error", ex, "unexpected_stream_end"); if (!emitted.get() && canSwitch && index < routes.size() - 1) { log.warn("LLM 路由流异常完成,自动切换,current={}", route.tag()); attemptStream(routes, index + 1, req, onChunk, onComplete, onError, traceId, scene); } else { if (onError != null) onError.accept(ex); } } else { markSuccess(route); recordCall(traceId, scene, true, index + 1, route, true, 200, System.currentTimeMillis() - start, routeReq, outputBuffer.toString(), "none", null, null); doneSeen.set(true); } markSuccess(route); recordCall(traceId, scene, true, index + 1, route, true, 200, System.currentTimeMillis() - start, routeReq, outputBuffer.toString(), "none", null, null); doneSeen.set(true); }); } private Flux<String> streamFlux(ResolvedRoute route, ChatCompletionRequest req) { WebClient client = WebClient.builder().baseUrl(route.baseUrl).build(); return client.post() .uri("/chat/completions") .header(HttpHeaders.AUTHORIZATION, "Bearer " + route.apiKey) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.TEXT_EVENT_STREAM) .bodyValue(req) .exchangeToFlux(resp -> { int status = resp.rawStatusCode(); if (status >= 200 && status < 300) { return resp.bodyToFlux(String.class); } return resp.bodyToMono(String.class) .defaultIfEmpty("") .flatMapMany(body -> Flux.error(new LlmRouteException(status, body))); }) .doOnError(ex -> log.error("调用 LLM 流式失败, route={}", route.tag(), ex)); } private Flux<String> streamFluxWithSpringAi(ResolvedRoute route, ChatCompletionRequest req) { OpenAiApi api = buildOpenAiApi(route); OpenAiApi.ChatCompletionRequest springReq = buildSpringAiRequest(route, req, true); return api.chatCompletionStream(springReq) .flatMapIterable(chunk -> chunk == null || chunk.choices() == null ? List.<OpenAiApi.ChatCompletionChunk.ChunkChoice>of() : chunk.choices()) .map(OpenAiApi.ChatCompletionChunk.ChunkChoice::delta) .filter(Objects::nonNull) .map(this::extractSpringAiContent) .filter(text -> text != null && !text.isEmpty()) return llmSpringAiClientService.streamCompletion(route.baseUrl, route.apiKey, req) .doOnError(ex -> log.error("调用 Spring AI 流式失败, route={}", route.tag(), ex)); } private CompletionCallResult callCompletion(ResolvedRoute route, ChatCompletionRequest req) { if (canUseSpringAi(req)) { return callCompletionWithSpringAi(route, req); } return callCompletionWithWebClient(route, req); } private CompletionCallResult callCompletionWithWebClient(ResolvedRoute route, ChatCompletionRequest req) { WebClient client = WebClient.builder().baseUrl(route.baseUrl).build(); RawCompletionResult raw = client.post() .uri("/chat/completions") .header(HttpHeaders.AUTHORIZATION, "Bearer " + route.apiKey) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON, MediaType.TEXT_EVENT_STREAM) .bodyValue(req) .exchangeToMono(resp -> resp.bodyToFlux(String.class) .collectList() .map(list -> new RawCompletionResult(resp.rawStatusCode(), String.join("\\n\\n", list)))) .block(); if (raw == null) { throw new RuntimeException("LLM 返回为空"); } if (raw.statusCode < 200 || raw.statusCode >= 300) { throw new LlmRouteException(raw.statusCode, raw.payload); } return new CompletionCallResult(raw.statusCode, raw.payload, parseCompletion(raw.payload)); return callCompletionWithSpringAi(route, req); } private CompletionCallResult callCompletionWithSpringAi(ResolvedRoute route, ChatCompletionRequest req) { OpenAiApi api = buildOpenAiApi(route); OpenAiApi.ChatCompletionRequest springReq = buildSpringAiRequest(route, req, false); ResponseEntity<OpenAiApi.ChatCompletion> entity = api.chatCompletionEntity(springReq); OpenAiApi.ChatCompletion body = entity.getBody(); return new CompletionCallResult(entity.getStatusCode().value(), body == null ? null : JSON.toJSONString(body), toLegacyResponse(body)); LlmSpringAiClientService.CompletionCallResult result = llmSpringAiClientService.callCompletion(route.baseUrl, route.apiKey, req); return new CompletionCallResult(result.getStatusCode(), result.getPayload(), result.getResponse()); } private ChatCompletionRequest applyRoute(ChatCompletionRequest req, ResolvedRoute route, boolean stream) { @@ -459,10 +345,6 @@ return quota ? route.switchOnQuota : route.switchOnError; } private boolean canUseSpringAi(ChatCompletionRequest req) { return req != null && (req.getTools() == null || req.getTools().isEmpty()); } private void markSuccess(ResolvedRoute route) { if (route.id != null) { llmRoutingService.markSuccess(route.id); @@ -477,14 +359,6 @@ private String errorText(Throwable ex) { if (ex == null) return "unknown"; if (ex instanceof LlmRouteException) { LlmRouteException e = (LlmRouteException) ex; String body = e.body == null ? "" : e.body; if (body.length() > 240) { body = body.substring(0, 240); } return "status=" + e.statusCode + ", body=" + body; } if (ex instanceof RestClientResponseException) { RestClientResponseException e = (RestClientResponseException) ex; String body = e.getResponseBodyAsString(); @@ -500,6 +374,10 @@ body = body.substring(0, 240); } return "status=" + e.getStatusCode().value() + ", body=" + body; } Integer springAiStatus = llmSpringAiClientService.statusCodeOf(ex); if (springAiStatus != null) { return "status=" + springAiStatus + ", body=" + llmSpringAiClientService.responseBodyOf(ex, 240); } return ex.getMessage() == null ? ex.toString() : ex.getMessage(); } @@ -541,98 +419,6 @@ return s == null || s.trim().isEmpty(); } private ChatCompletionResponse mergeSseChunk(ChatCompletionResponse acc, String payload) { if (payload == null || payload.isEmpty()) return acc; String[] events = payload.split("\\r?\\n\\r?\\n"); for (String part : events) { String s = part; if (s == null || s.isEmpty()) continue; if (s.startsWith("data:")) { s = s.substring(5); if (s.startsWith(" ")) s = s.substring(1); } if ("[DONE]".equals(s.trim())) { continue; } try { JSONObject obj = JSON.parseObject(s); if (obj == null) continue; JSONArray choices = obj.getJSONArray("choices"); if (choices != null && !choices.isEmpty()) { JSONObject c0 = choices.getJSONObject(0); if (acc.getChoices() == null || acc.getChoices().isEmpty()) { ChatCompletionResponse.Choice choice = new ChatCompletionResponse.Choice(); ChatCompletionRequest.Message msg = new ChatCompletionRequest.Message(); choice.setMessage(msg); ArrayList<ChatCompletionResponse.Choice> list = new ArrayList<>(); list.add(choice); acc.setChoices(list); } ChatCompletionResponse.Choice choice = acc.getChoices().get(0); ChatCompletionRequest.Message msg = choice.getMessage(); if (msg.getRole() == null || msg.getRole().isEmpty()) { msg.setRole("assistant"); } JSONObject delta = c0.getJSONObject("delta"); if (delta != null) { String c = delta.getString("content"); if (c != null) { String prev = msg.getContent(); msg.setContent(prev == null ? c : prev + c); } String role = delta.getString("role"); if (role != null && !role.isEmpty()) msg.setRole(role); } JSONObject message = c0.getJSONObject("message"); if (message != null) { String c = message.getString("content"); if (c != null) { String prev = msg.getContent(); msg.setContent(prev == null ? c : prev + c); } String role = message.getString("role"); if (role != null && !role.isEmpty()) msg.setRole(role); } String fr = c0.getString("finish_reason"); if (fr != null && !fr.isEmpty()) choice.setFinishReason(fr); } String id = obj.getString("id"); if (id != null && !id.isEmpty()) acc.setId(id); Long created = obj.getLong("created"); if (created != null) acc.setCreated(created); String object = obj.getString("object"); if (object != null && !object.isEmpty()) acc.setObjectName(object); } catch (Exception ignore) { } } return acc; } private ChatCompletionResponse parseCompletion(String payload) { if (payload == null) return null; try { ChatCompletionResponse r = JSON.parseObject(payload, ChatCompletionResponse.class); if (r != null && r.getChoices() != null && !r.getChoices().isEmpty() && r.getChoices().get(0).getMessage() != null) { return r; } } catch (Exception ignore) { } ChatCompletionResponse sse = mergeSseChunk(new ChatCompletionResponse(), payload); if (sse.getChoices() != null && !sse.getChoices().isEmpty() && sse.getChoices().get(0).getMessage() != null && sse.getChoices().get(0).getMessage().getContent() != null) { return sse; } ChatCompletionResponse r = new ChatCompletionResponse(); ChatCompletionResponse.Choice choice = new ChatCompletionResponse.Choice(); ChatCompletionRequest.Message msg = new ChatCompletionRequest.Message(); msg.setRole("assistant"); msg.setContent(payload); choice.setMessage(msg); ArrayList<ChatCompletionResponse.Choice> list = new ArrayList<>(); list.add(choice); r.setChoices(list); return r; } private String nextTraceId() { return UUID.randomUUID().toString().replace("-", ""); } @@ -653,29 +439,23 @@ } private Integer statusCodeOf(Throwable ex) { if (ex instanceof LlmRouteException) { return ((LlmRouteException) ex).statusCode; } if (ex instanceof RestClientResponseException) { return ((RestClientResponseException) ex).getStatusCode().value(); } if (ex instanceof WebClientResponseException) { return ((WebClientResponseException) ex).getStatusCode().value(); } return null; return llmSpringAiClientService.statusCodeOf(ex); } private String responseBodyOf(Throwable ex) { if (ex instanceof LlmRouteException) { return cut(((LlmRouteException) ex).body, LOG_TEXT_LIMIT); } if (ex instanceof RestClientResponseException) { return cut(((RestClientResponseException) ex).getResponseBodyAsString(), LOG_TEXT_LIMIT); } if (ex instanceof WebClientResponseException) { return cut(((WebClientResponseException) ex).getResponseBodyAsString(), LOG_TEXT_LIMIT); } return null; return cut(llmSpringAiClientService.responseBodyOf(ex, LOG_TEXT_LIMIT), LOG_TEXT_LIMIT); } private String buildResponseText(ChatCompletionResponse resp, String fallbackPayload) { @@ -694,158 +474,6 @@ private String safeName(Throwable ex) { return ex == null ? null : ex.getClass().getSimpleName(); } private OpenAiApi buildOpenAiApi(ResolvedRoute route) { return OpenAiApi.builder() .baseUrl(route.baseUrl) .apiKey(route.apiKey) .build(); } private OpenAiApi.ChatCompletionRequest buildSpringAiRequest(ResolvedRoute route, ChatCompletionRequest req, boolean stream) { HashMap<String, Object> extraBody = new HashMap<>(); if (route.thinkingEnabled || req.getThinking() != null) { HashMap<String, Object> thinking = new HashMap<>(); thinking.put("type", req.getThinking() != null && req.getThinking().getType() != null ? req.getThinking().getType() : "enable"); extraBody.put("thinking", thinking); } return new OpenAiApi.ChatCompletionRequest( toSpringAiMessages(req.getMessages()), route.model, null, null, null, null, null, null, req.getMax_tokens(), null, 1, null, null, null, null, null, null, null, stream, stream ? OpenAiApi.ChatCompletionRequest.StreamOptions.INCLUDE_USAGE : null, req.getTemperature(), null, null, null, null, null, null, null, null, null, null, extraBody.isEmpty() ? null : extraBody ); } private List<OpenAiApi.ChatCompletionMessage> toSpringAiMessages(List<ChatCompletionRequest.Message> messages) { ArrayList<OpenAiApi.ChatCompletionMessage> result = new ArrayList<>(); if (messages == null) { return result; } for (ChatCompletionRequest.Message message : messages) { if (message == null) { continue; } result.add(new OpenAiApi.ChatCompletionMessage( message.getContent(), toSpringAiRole(message.getRole()) )); } return result; } private OpenAiApi.ChatCompletionMessage.Role toSpringAiRole(String role) { if (role == null) { return OpenAiApi.ChatCompletionMessage.Role.USER; } switch (role.trim().toLowerCase(Locale.ROOT)) { case "system": return OpenAiApi.ChatCompletionMessage.Role.SYSTEM; case "assistant": return OpenAiApi.ChatCompletionMessage.Role.ASSISTANT; case "tool": return OpenAiApi.ChatCompletionMessage.Role.TOOL; default: return OpenAiApi.ChatCompletionMessage.Role.USER; } } private ChatCompletionResponse toLegacyResponse(OpenAiApi.ChatCompletion completion) { if (completion == null) { return null; } ChatCompletionResponse response = new ChatCompletionResponse(); response.setId(completion.id()); response.setCreated(completion.created()); response.setObjectName(completion.object()); if (completion.usage() != null) { ChatCompletionResponse.Usage usage = new ChatCompletionResponse.Usage(); usage.setPromptTokens(completion.usage().promptTokens()); usage.setCompletionTokens(completion.usage().completionTokens()); usage.setTotalTokens(completion.usage().totalTokens()); response.setUsage(usage); } if (completion.choices() != null) { ArrayList<ChatCompletionResponse.Choice> choices = new ArrayList<>(); for (OpenAiApi.ChatCompletion.Choice choice : completion.choices()) { ChatCompletionResponse.Choice item = new ChatCompletionResponse.Choice(); item.setIndex(choice.index()); if (choice.finishReason() != null) { item.setFinishReason(choice.finishReason().name().toLowerCase(Locale.ROOT)); } item.setMessage(toLegacyMessage(choice.message())); choices.add(item); } response.setChoices(choices); } return response; } private ChatCompletionRequest.Message toLegacyMessage(OpenAiApi.ChatCompletionMessage message) { if (message == null) { return null; } ChatCompletionRequest.Message result = new ChatCompletionRequest.Message(); result.setContent(extractSpringAiContent(message)); if (message.role() != null) { result.setRole(message.role().name().toLowerCase(Locale.ROOT)); } result.setName(message.name()); result.setTool_call_id(message.toolCallId()); return result; } private String extractSpringAiContent(OpenAiApi.ChatCompletionMessage message) { if (message == null || message.rawContent() == null) { return null; } Object content = message.rawContent(); if (content instanceof String) { return (String) content; } if (content instanceof List) { try { @SuppressWarnings("unchecked") List<OpenAiApi.ChatCompletionMessage.MediaContent> media = (List<OpenAiApi.ChatCompletionMessage.MediaContent>) content; return OpenAiApi.getTextContent(media); } catch (ClassCastException ignore) { } } return String.valueOf(content); } private String cut(String text, int maxLen) { @@ -900,27 +528,6 @@ this.statusCode = statusCode; this.payload = payload; this.response = response; } } private static class RawCompletionResult { private final int statusCode; private final String payload; private RawCompletionResult(int statusCode, String payload) { this.statusCode = statusCode; this.payload = payload; } } private static class LlmRouteException extends RuntimeException { private final int statusCode; private final String body; private LlmRouteException(int statusCode, String body) { super("http status=" + statusCode); this.statusCode = statusCode; this.body = body; } } src/main/java/com/zy/ai/service/LlmRoutingService.java
@@ -1,16 +1,12 @@ package com.zy.ai.service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.zy.ai.entity.ChatCompletionRequest; import com.zy.ai.entity.LlmRouteConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; import java.time.Duration; import java.util.ArrayList; import java.util.Comparator; import java.util.Collections; @@ -27,6 +23,7 @@ private static final long CACHE_TTL_MS = 3000L; private final LlmRouteConfigService llmRouteConfigService; private final LlmSpringAiClientService llmSpringAiClientService; private volatile List<LlmRouteConfig> allRouteCache = Collections.emptyList(); private volatile long cacheExpireAt = 0L; @@ -242,31 +239,31 @@ } private TestHttpResult testJavaRoute(LlmRouteConfig cfg) { HashMap<String, Object> req = new HashMap<>(); req.put("model", cfg.getModel()); List<Map<String, String>> messages = new ArrayList<>(); HashMap<String, String> msg = new HashMap<>(); msg.put("role", "user"); msg.put("content", "ping"); ChatCompletionRequest req = new ChatCompletionRequest(); req.setModel(cfg.getModel()); List<ChatCompletionRequest.Message> messages = new ArrayList<>(); ChatCompletionRequest.Message msg = new ChatCompletionRequest.Message(); msg.setRole("user"); msg.setContent("ping"); messages.add(msg); req.put("messages", messages); req.put("stream", false); req.put("max_tokens", 8); req.put("temperature", 0); WebClient client = WebClient.builder().baseUrl(cfg.getBaseUrl()).build(); return client.post() .uri("/chat/completions") .header(HttpHeaders.AUTHORIZATION, "Bearer " + cfg.getApiKey()) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON, MediaType.TEXT_EVENT_STREAM) .bodyValue(req) .exchangeToMono(resp -> resp.bodyToMono(String.class) .defaultIfEmpty("") .map(body -> new TestHttpResult(resp.rawStatusCode(), body))) .timeout(Duration.ofSeconds(12)) .onErrorResume(ex -> Mono.just(new TestHttpResult(-1, safe(ex.getMessage())))) .block(); req.setMessages(messages); req.setStream(false); req.setMax_tokens(8); req.setTemperature(0D); if (cfg.getThinking() != null && cfg.getThinking() == 1) { ChatCompletionRequest.Thinking thinking = new ChatCompletionRequest.Thinking(); thinking.setType("enable"); req.setThinking(thinking); } try { LlmSpringAiClientService.CompletionCallResult result = llmSpringAiClientService.callCompletion(cfg.getBaseUrl(), cfg.getApiKey(), req); return new TestHttpResult(result.getStatusCode(), result.getPayload()); } catch (Throwable ex) { Integer statusCode = llmSpringAiClientService.statusCodeOf(ex); String body = llmSpringAiClientService.responseBodyOf(ex, 300); return new TestHttpResult(statusCode == null ? -1 : statusCode, safe(body != null ? body : ex.getMessage())); } } private String trimBody(String body) { src/main/java/com/zy/ai/service/LlmSpringAiClientService.java
New file @@ -0,0 +1,441 @@ package com.zy.ai.service; import com.alibaba.fastjson.JSON; import com.zy.ai.entity.ChatCompletionRequest; import com.zy.ai.entity.ChatCompletionResponse; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; import io.netty.channel.ChannelOption; import reactor.netty.http.client.HttpClient; import reactor.core.publisher.Flux; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @Service public class LlmSpringAiClientService { @Value("${llm.connect-timeout-ms:10000}") private int connectTimeoutMs; @Value("${llm.read-timeout-ms:120000}") private int readTimeoutMs; public CompletionCallResult callCompletion(String baseUrl, String apiKey, ChatCompletionRequest req) { OpenAiApi api = buildOpenAiApi(baseUrl, apiKey); OpenAiApi.ChatCompletionRequest springReq = buildSpringAiRequest(req, false); ResponseEntity<OpenAiApi.ChatCompletion> entity = api.chatCompletionEntity(springReq); OpenAiApi.ChatCompletion body = entity.getBody(); ChatCompletionResponse legacy = toLegacyResponse(body); return new CompletionCallResult(entity.getStatusCode().value(), serializeResponsePayload(body, legacy), legacy); } public Flux<String> streamCompletion(String baseUrl, String apiKey, ChatCompletionRequest req) { OpenAiApi api = buildOpenAiApi(baseUrl, apiKey); OpenAiApi.ChatCompletionRequest springReq = buildSpringAiRequest(req, true); return api.chatCompletionStream(springReq) .flatMapIterable(chunk -> chunk == null || chunk.choices() == null ? List.<OpenAiApi.ChatCompletionChunk.ChunkChoice>of() : chunk.choices()) .map(OpenAiApi.ChatCompletionChunk.ChunkChoice::delta) .filter(delta -> delta != null) .handle((delta, sink) -> { String text = extractSpringAiContent(delta); if (text != null && !text.isEmpty()) { sink.next(text); } }); } public Integer statusCodeOf(Throwable ex) { if (ex == null || ex.getMessage() == null) { return null; } String text = ex.getMessage().trim(); int idx = text.indexOf(" - "); String prefix = idx >= 0 ? text.substring(0, idx).trim() : text; try { return Integer.parseInt(prefix); } catch (NumberFormatException ignore) { return null; } } public String responseBodyOf(Throwable ex, int maxLen) { if (ex == null || ex.getMessage() == null) { return null; } String text = ex.getMessage().trim(); int idx = text.indexOf(" - "); String body = idx >= 0 ? text.substring(idx + 3).trim() : text; if (body.length() > maxLen) { return body.substring(0, maxLen); } return body; } private OpenAiApi buildOpenAiApi(String baseUrl, String apiKey) { HttpClient httpClient = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeoutMs) .responseTimeout(Duration.ofMillis(readTimeoutMs)); WebClient.Builder webClientBuilder = WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)); RestClient.Builder restClientBuilder = RestClient.builder() .requestFactory(buildRequestFactory()); return OpenAiApi.builder() .baseUrl(baseUrl) .apiKey(apiKey) .restClientBuilder(restClientBuilder) .webClientBuilder(webClientBuilder) .build(); } private SimpleClientHttpRequestFactory buildRequestFactory() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(connectTimeoutMs); factory.setReadTimeout(readTimeoutMs); return factory; } private OpenAiApi.ChatCompletionRequest buildSpringAiRequest(ChatCompletionRequest req, boolean stream) { HashMap<String, Object> extraBody = new HashMap<>(); if (req != null && req.getThinking() != null) { HashMap<String, Object> thinking = new HashMap<>(); thinking.put("type", req.getThinking().getType() != null ? req.getThinking().getType() : "enable"); extraBody.put("thinking", thinking); } return new OpenAiApi.ChatCompletionRequest( toSpringAiMessages(req == null ? null : req.getMessages()), req == null ? null : req.getModel(), null, null, null, null, null, null, req == null ? null : req.getMax_tokens(), null, 1, null, null, null, null, null, null, null, stream, stream ? OpenAiApi.ChatCompletionRequest.StreamOptions.INCLUDE_USAGE : null, req == null ? null : req.getTemperature(), null, toSpringAiTools(req == null ? null : req.getTools()), toSpringAiToolChoice(req == null ? null : req.getTool_choice()), null, null, null, null, null, null, null, extraBody.isEmpty() ? null : extraBody ); } private List<OpenAiApi.ChatCompletionMessage> toSpringAiMessages(List<ChatCompletionRequest.Message> messages) { ArrayList<OpenAiApi.ChatCompletionMessage> result = new ArrayList<>(); if (messages == null) { return result; } for (ChatCompletionRequest.Message message : messages) { if (message == null) { continue; } result.add(new OpenAiApi.ChatCompletionMessage( message.getContent(), toSpringAiRole(message.getRole()), message.getName(), message.getTool_call_id(), toSpringAiToolCalls(message.getTool_calls()), null, null, null, null )); } return result; } private List<OpenAiApi.ChatCompletionMessage.ToolCall> toSpringAiToolCalls(List<ChatCompletionRequest.ToolCall> toolCalls) { if (toolCalls == null || toolCalls.isEmpty()) { return null; } ArrayList<OpenAiApi.ChatCompletionMessage.ToolCall> result = new ArrayList<>(); for (ChatCompletionRequest.ToolCall toolCall : toolCalls) { if (toolCall == null) { continue; } ChatCompletionRequest.Function function = toolCall.getFunction(); OpenAiApi.ChatCompletionMessage.ChatCompletionFunction springFunction = new OpenAiApi.ChatCompletionMessage.ChatCompletionFunction( function == null ? null : function.getName(), function == null ? null : function.getArguments() ); result.add(new OpenAiApi.ChatCompletionMessage.ToolCall( toolCall.getId(), toolCall.getType(), springFunction )); } return result.isEmpty() ? null : result; } private OpenAiApi.ChatCompletionMessage.Role toSpringAiRole(String role) { if (role == null) { return OpenAiApi.ChatCompletionMessage.Role.USER; } switch (role.trim().toLowerCase(Locale.ROOT)) { case "system": return OpenAiApi.ChatCompletionMessage.Role.SYSTEM; case "assistant": return OpenAiApi.ChatCompletionMessage.Role.ASSISTANT; case "tool": return OpenAiApi.ChatCompletionMessage.Role.TOOL; default: return OpenAiApi.ChatCompletionMessage.Role.USER; } } private List<OpenAiApi.FunctionTool> toSpringAiTools(List<Object> tools) { if (tools == null || tools.isEmpty()) { return null; } ArrayList<OpenAiApi.FunctionTool> result = new ArrayList<>(); for (Object tool : tools) { OpenAiApi.FunctionTool mapped = toSpringAiTool(tool); if (mapped != null) { result.add(mapped); } } return result.isEmpty() ? null : result; } @SuppressWarnings("unchecked") private OpenAiApi.FunctionTool toSpringAiTool(Object tool) { if (tool instanceof OpenAiApi.FunctionTool) { return (OpenAiApi.FunctionTool) tool; } if (!(tool instanceof Map)) { return null; } Map<String, Object> toolMap = (Map<String, Object>) tool; Object functionObj = toolMap.get("function"); if (!(functionObj instanceof Map)) { return null; } Map<String, Object> functionMap = (Map<String, Object>) functionObj; Object name = functionMap.get("name"); if (name == null) { return null; } Object description = functionMap.get("description"); Object parameters = functionMap.get("parameters"); Map<String, Object> parametersMap; if (parameters instanceof Map) { parametersMap = new HashMap<>((Map<String, Object>) parameters); } else { parametersMap = Collections.emptyMap(); } OpenAiApi.FunctionTool.Function function = new OpenAiApi.FunctionTool.Function( String.valueOf(description == null ? "" : description), String.valueOf(name), parametersMap, null ); return new OpenAiApi.FunctionTool(OpenAiApi.FunctionTool.Type.FUNCTION, function); } @SuppressWarnings("unchecked") private Object toSpringAiToolChoice(Object toolChoice) { if (toolChoice == null) { return null; } if (toolChoice instanceof String) { String text = ((String) toolChoice).trim(); if (text.isEmpty()) { return null; } if ("auto".equalsIgnoreCase(text) || "none".equalsIgnoreCase(text)) { return text.toLowerCase(Locale.ROOT); } return OpenAiApi.ChatCompletionRequest.ToolChoiceBuilder.function(text); } if (toolChoice instanceof Map) { Map<String, Object> choiceMap = (Map<String, Object>) toolChoice; Object type = choiceMap.get("type"); Object function = choiceMap.get("function"); if ("function".equals(type) && function instanceof Map) { Object name = ((Map<String, Object>) function).get("name"); if (name != null) { return OpenAiApi.ChatCompletionRequest.ToolChoiceBuilder.function(String.valueOf(name)); } } } return toolChoice; } private ChatCompletionResponse toLegacyResponse(OpenAiApi.ChatCompletion completion) { if (completion == null) { return null; } ChatCompletionResponse response = new ChatCompletionResponse(); response.setId(completion.id()); response.setCreated(completion.created()); response.setObjectName(completion.object()); if (completion.usage() != null) { ChatCompletionResponse.Usage usage = new ChatCompletionResponse.Usage(); usage.setPromptTokens(completion.usage().promptTokens()); usage.setCompletionTokens(completion.usage().completionTokens()); usage.setTotalTokens(completion.usage().totalTokens()); response.setUsage(usage); } if (completion.choices() != null) { ArrayList<ChatCompletionResponse.Choice> choices = new ArrayList<>(); for (OpenAiApi.ChatCompletion.Choice choice : completion.choices()) { ChatCompletionResponse.Choice item = new ChatCompletionResponse.Choice(); item.setIndex(choice.index()); if (choice.finishReason() != null) { item.setFinishReason(choice.finishReason().name().toLowerCase(Locale.ROOT)); } item.setMessage(toLegacyMessage(choice.message())); choices.add(item); } response.setChoices(choices); } return response; } private String serializeResponsePayload(OpenAiApi.ChatCompletion body, ChatCompletionResponse legacy) { if (legacy != null) { String content = firstMessageContent(legacy); if (content != null && !content.trim().isEmpty()) { return content; } String legacyJson = JSON.toJSONString(legacy); if (legacyJson != null && !legacyJson.trim().isEmpty() && !"{}".equals(legacyJson.trim())) { return legacyJson; } } if (body != null) { String raw = body.toString(); if (raw != null && !raw.trim().isEmpty()) { return raw; } } return null; } private String firstMessageContent(ChatCompletionResponse response) { if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) { return null; } ChatCompletionResponse.Choice choice = response.getChoices().get(0); if (choice == null || choice.getMessage() == null) { return null; } return choice.getMessage().getContent(); } private ChatCompletionRequest.Message toLegacyMessage(OpenAiApi.ChatCompletionMessage message) { if (message == null) { return null; } ChatCompletionRequest.Message result = new ChatCompletionRequest.Message(); result.setContent(extractSpringAiContent(message)); if (message.role() != null) { result.setRole(message.role().name().toLowerCase(Locale.ROOT)); } result.setName(message.name()); result.setTool_call_id(message.toolCallId()); result.setTool_calls(toLegacyToolCalls(message.toolCalls())); return result; } private List<ChatCompletionRequest.ToolCall> toLegacyToolCalls(List<OpenAiApi.ChatCompletionMessage.ToolCall> toolCalls) { if (toolCalls == null || toolCalls.isEmpty()) { return null; } ArrayList<ChatCompletionRequest.ToolCall> result = new ArrayList<>(); for (OpenAiApi.ChatCompletionMessage.ToolCall toolCall : toolCalls) { if (toolCall == null) { continue; } ChatCompletionRequest.ToolCall legacy = new ChatCompletionRequest.ToolCall(); legacy.setId(toolCall.id()); legacy.setType(toolCall.type()); if (toolCall.function() != null) { ChatCompletionRequest.Function function = new ChatCompletionRequest.Function(); function.setName(toolCall.function().name()); function.setArguments(toolCall.function().arguments()); legacy.setFunction(function); } result.add(legacy); } return result.isEmpty() ? null : result; } private String extractSpringAiContent(OpenAiApi.ChatCompletionMessage message) { if (message == null || message.rawContent() == null) { return null; } Object content = message.rawContent(); if (content instanceof String) { return (String) content; } if (content instanceof List) { try { @SuppressWarnings("unchecked") List<OpenAiApi.ChatCompletionMessage.MediaContent> media = (List<OpenAiApi.ChatCompletionMessage.MediaContent>) content; return OpenAiApi.getTextContent(media); } catch (ClassCastException ignore) { } } return String.valueOf(content); } public static class CompletionCallResult { private final int statusCode; private final String payload; private final ChatCompletionResponse response; public CompletionCallResult(int statusCode, String payload, ChatCompletionResponse response) { this.statusCode = statusCode; this.payload = payload; this.response = response; } public int getStatusCode() { return statusCode; } public String getPayload() { return payload; } public ChatCompletionResponse getResponse() { return response; } } } src/main/resources/application.yml
@@ -97,6 +97,9 @@ llm: # 现已迁移到数据库表 sys_llm_route 维护(支持多API/多模型/多Key自动切换) # 以下仅作为数据库为空时的兼容回退配置 # SpringAI 客户端超时配置 connect-timeout-ms: 10000 read-timeout-ms: 12000 thinking: false base-url: api-key: src/main/webapp/views/ai/diagnosis.html
@@ -6,94 +6,640 @@ <title>WCS AI 助手</title> <link rel="stylesheet" href="../../static/vue/element/element.css" /> <style> body { background: #f5f7fa; } .container { max-width: 1100px; margin: 24px auto; } .actions { display: flex; gap: 12px; align-items: center; } .output { height: 60vh; } .markdown-body { font-size: 14px; line-height: 1.4; white-space: pre-wrap; word-break: break-word; } .markdown-body p { margin: 4px 0; } .markdown-body ul, .markdown-body ol { margin: 4px 0 4px 16px; padding: 0; } .markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 8px; } .markdown-body pre { background: #f6f8fa; padding: 12px; border-radius: 6px; overflow: auto; } .status { color: #909399; } .chat { display: flex; flex-direction: column; gap: 10px; height: 100%; overflow-y: auto; padding-right: 8px; } .msg { display: flex; align-items: flex-start; } .msg.user { justify-content: flex-end; } .msg.assistant { justify-content: flex-start; } .bubble { max-width: 72%; padding: 10px 12px; border-radius: 16px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; } .assistant .bubble { background: #ffffff; border: 1px solid #ebeef5; color: #303133; } .user .bubble { background: #409EFF; color: #ffffff; } .composer { display: flex; gap: 10px; align-items: center; margin-top: 12px; } .avatar { width: 24px; height: 24px; display: flex; align-items: center; margin-right: 8px; } .time { font-size: 12px; color: #909399; text-align: right; margin-top: 6px; } .output .el-card__body { height: 100%; padding: 0; } .assistant-running { display: flex; align-items: center; gap: 8px; color: #909399; } details.think-block { border: 1px solid #e4e7ed; border-radius: 4px; padding: 8px; margin: 8px 0; background-color: #fcfcfc; html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; } body { background: #ffffff; color: #303133; font-family: "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif; } * { box-sizing: border-box; } .drawer-shell { height: 100%; background: #fff; } .assistant-page { display: flex; flex-direction: column; height: 100%; min-height: 0; } .assistant-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-bottom: 1px solid #ebeef5; background: #fff; flex-shrink: 0; } .assistant-header-main { display: flex; align-items: center; gap: 10px; min-width: 0; } .assistant-header-icon { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: 8px; background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%); box-shadow: 0 4px 10px rgba(64, 158, 255, 0.18); flex-shrink: 0; } .assistant-header-text { min-width: 0; } .assistant-header-text strong { display: block; font-size: 18px; line-height: 1.2; color: #303133; } .assistant-header-text span { display: block; margin-top: 2px; font-size: 12px; color: #909399; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .assistant-header-side { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .status-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 999px; background: #f4f4f5; color: #606266; font-size: 12px; font-weight: 600; } .status-chip::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #909399; } .status-chip.streaming { background: #ecf5ff; color: #409eff; } .status-chip.streaming::before { background: #409eff; } .status-chip.loading { background: #fdf6ec; color: #e6a23c; } .status-chip.loading::before { background: #e6a23c; } .toolbar-panel { padding: 10px 14px; border-bottom: 1px solid #ebeef5; background: #fafbfd; flex-shrink: 0; } .toolbar-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .toolbar-row + .toolbar-row { margin-top: 8px; } .toolbar-tip { flex: 1; min-width: 0; padding: 0 2px; color: #606266; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .session-select { flex: 1; min-width: 220px; } .session-count { color: #909399; font-size: 12px; white-space: nowrap; } .assistant-content { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 10px; padding: 12px; background: #f5f7fa; } .chat-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; overflow: hidden; } .chat-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 14px; } .empty-state { height: 100%; min-height: 260px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: #909399; padding: 20px; } .empty-state strong { font-size: 16px; color: #303133; margin-bottom: 6px; } .empty-state p { max-width: 420px; margin: 0; line-height: 1.7; font-size: 13px; } .empty-presets { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 16px; } .empty-presets button, .quick-actions button { border: 1px solid #dcdfe6; background: #fff; color: #606266; border-radius: 4px; padding: 8px 12px; cursor: pointer; transition: border-color 0.18s ease, color 0.18s ease, background 0.18s ease; font: inherit; } .empty-presets button:hover, .quick-actions button:hover { border-color: #409eff; color: #409eff; background: #ecf5ff; } .message-row { display: flex; gap: 10px; margin-bottom: 14px; } .message-row.user { justify-content: flex-end; } .message-row.user .message-avatar { order: 2; } .message-row.user .message-content { order: 1; align-items: flex-end; } .message-avatar { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; } .message-content { display: flex; flex-direction: column; max-width: 82%; min-width: 0; } .message-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 12px; color: #909399; } .message-meta strong { font-weight: 600; color: #606266; } .message-bubble { padding: 12px 14px; border-radius: 8px; border: 1px solid #ebeef5; background: #fff; color: #303133; line-height: 1.6; word-break: break-word; } .message-bubble.user { background: #409eff; border-color: #409eff; color: #fff; } .message-bubble.user .plain-text, .message-bubble.user .markdown-body { color: inherit; } .plain-text { white-space: pre-wrap; word-break: break-word; font-size: 14px; line-height: 1.6; } .message-time { margin-top: 6px; font-size: 12px; color: #909399; } .assistant-running { display: inline-flex; align-items: center; gap: 8px; color: #909399; min-height: 24px; } .typing-dot { width: 8px; height: 8px; border-radius: 50%; background: #409eff; box-shadow: 12px 0 0 rgba(64, 158, 255, 0.6), 24px 0 0 rgba(64, 158, 255, 0.3); margin-right: 24px; animation: pulseDots 1.2s infinite linear; } @keyframes pulseDots { 0% { transform: translateX(0); opacity: 0.6; } 50% { transform: translateX(2px); opacity: 1; } 100% { transform: translateX(0); opacity: 0.6; } } .markdown-body { font-size: 14px; line-height: 1.7; white-space: normal; word-break: break-word; } .markdown-body > :first-child { margin-top: 0; } .markdown-body > :last-child { margin-bottom: 0; } .markdown-body p { margin: 0 0 10px; } .markdown-body ul, .markdown-body ol { margin: 0 0 10px 18px; padding: 0; } .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { margin: 14px 0 8px; line-height: 1.4; } .markdown-body pre { margin: 10px 0; padding: 12px; border-radius: 4px; overflow-x: auto; background: #2b2f3a; color: #eff5fb; font-size: 13px; } .markdown-body code { font-family: "SFMono-Regular", "Consolas", monospace; } .markdown-body p code, .markdown-body li code { background: #f5f7fa; color: #337ecc; padding: 2px 6px; border-radius: 3px; } .markdown-body blockquote { margin: 10px 0; padding: 10px 12px; border-left: 4px solid #409eff; background: #ecf5ff; color: #606266; } details.think-block { border: 1px solid #d9ecff; border-radius: 4px; padding: 10px 12px; margin: 10px 0; background: #f5faff; } details.think-block summary { cursor: pointer; color: #909399; color: #409eff; font-size: 13px; font-weight: bold; font-weight: 600; outline: none; } details.think-block .content { margin-top: 8px; color: #606266; font-size: 13px; white-space: pre-wrap; line-height: 1.7; } .composer-panel { background: #fff; border: 1px solid #ebeef5; border-radius: 4px; padding: 12px; flex-shrink: 0; } .composer-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 12px; color: #909399; } .composer-head strong { color: #303133; font-size: 14px; } .composer-panel .el-textarea__inner { min-height: 92px !important; border-radius: 4px; resize: none; font-size: 14px; line-height: 1.6; padding: 10px 12px; font-family: inherit; } .composer-tools { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; flex-wrap: wrap; } .quick-actions { display: flex; gap: 8px; flex-wrap: wrap; } .composer-actions { display: flex; align-items: center; gap: 10px; margin-left: auto; flex-wrap: wrap; } .composer-hint { color: #909399; font-size: 12px; white-space: nowrap; } @media (max-width: 520px) { .toolbar-tip, .assistant-header-text span, .composer-hint { display: none; } .session-select { min-width: 100%; } .message-content { max-width: 100%; } } </style> </head> <body> <div id="app" class="container"> <el-card shadow="hover"> <div slot="header" class="clearfix" style="display: flex; align-items: center;"> <div v-html="headerIcon" style="margin-right: 10px; display: flex;"></div> <span>WCS AI 助手</span> </div> <div class="actions" style="flex-wrap: wrap;"> <el-button type="primary" :loading="loading" :disabled="streaming" @click="start">一键诊断系统</el-button> <el-button type="warning" :disabled="!streaming" @click="stop">停止</el-button> <!-- <el-button @click="clear">清空当前聊天</el-button> --> <span class="status" style="margin-right: 12px;">{{ statusText }}</span> <el-select v-model="currentChatId" placeholder="选择会话" style="min-width:240px;" @change="switchChat" :disabled="streaming"> <el-option v-for="c in chats" :key="c.chatId" :label="(c.title||('会话 '+c.chatId)) + '('+(c.size||0)+')'" :value="c.chatId" /> </el-select> <el-button type="success" plain icon="el-icon-plus" @click="newChat" :disabled="streaming">新会话</el-button> <el-button type="danger" plain icon="el-icon-delete" @click="deleteChat" :disabled="!currentChatId || streaming">删除会话</el-button> </div> <el-divider></el-divider> <el-card class="output" shadow="never"> <div ref="chat" class="chat"> <div v-for="(m,i) in messages" :key="i" class="msg" :class="m.role"> <div class="avatar" v-html="m.role === 'assistant' ? assistantIcon : userIcon"></div> <div class="bubble"> <div v-if="m.role === 'assistant' && m.html" class="markdown-body" v-html="m.html"></div> <div v-else-if="m.role === 'assistant' && streaming && i === messages.length - 1" class="assistant-running"> <span v-html="assistantIcon"></span> <span>AI助手正在运行中</span> </div> <div v-else v-text="m.text"></div> <div class="time">{{ m.ts }}</div> </div> <div id="app" class="drawer-shell"> <div class="assistant-page"> <header class="assistant-header"> <div class="assistant-header-main"> <div class="assistant-header-icon" v-html="headerIcon"></div> <div class="assistant-header-text"> <strong>AI 助手</strong> <span>系统巡检、异常问答、历史会话</span> </div> </div> </el-card> <div class="assistant-header-side"> <span class="status-chip" :class="{ streaming: streaming, loading: loading && !streaming }">{{ statusText }}</span> <el-button v-if="embedded" size="mini" icon="el-icon-close" @click="closeDrawer">关闭</el-button> </div> </header> <div class="composer"> <el-input v-model="userInput" placeholder="向 AI 助手提问" clearable :disabled="streaming" @keyup.enter.native="ask"></el-input> <el-button type="success" :disabled="sendDisabled" @click="ask">发送</el-button> </div> </el-card> <section class="toolbar-panel"> <div class="toolbar-row"> <el-button type="primary" size="small" :disabled="streaming" @click="start">一键巡检</el-button> <el-button size="small" :disabled="streaming" @click="newChat">新会话</el-button> <el-button size="small" type="danger" plain :disabled="!currentChatId || streaming" @click="deleteChat">删除</el-button> <el-button size="small" type="warning" plain :disabled="!streaming" @click="stop">停止</el-button> </div> <div class="toolbar-row"> <el-select v-model="currentChatId" class="session-select" size="small" filterable placeholder="选择历史会话" :disabled="streaming || (!sortedChats.length && !currentChatId)" @change="switchChat" > <el-option v-if="currentChatId && !findChat(currentChatId)" :key="currentChatId" :label="currentChatSummary" :value="currentChatId" ></el-option> <el-option v-for="chat in sortedChats" :key="chat.chatId" :label="chatOptionLabel(chat)" :value="chat.chatId" ></el-option> </el-select> <div class="toolbar-tip">{{ currentChatSummary }}</div> <div class="session-count">{{ sortedChats.length }} 个会话</div> </div> </section> <main class="assistant-content"> <section class="chat-panel"> <div ref="chat" class="chat-scroll"> <div v-if="!messages.length" class="empty-state"> <strong>输入问题,或先执行一次巡检</strong> <p>支持连续追问、历史会话切换,以及 AI 思考过程折叠展示。</p> <div class="empty-presets"> <button v-for="preset in promptPresets" :key="preset.title" @click="applyPreset(preset)"> {{ preset.title }} </button> </div> </div> <template v-else> <div v-for="(m, i) in messages" :key="i" class="message-row" :class="m.role"> <div class="message-avatar" v-html="m.role === 'assistant' ? assistantIcon : userIcon"></div> <div class="message-content"> <div class="message-meta"> <strong>{{ m.role === 'assistant' ? 'AI 助手' : '用户' }}</strong> <span>{{ m.role === 'assistant' ? 'WCS 诊断回复' : '问题输入' }}</span> </div> <div class="message-bubble" :class="m.role"> <div v-if="m.role === 'assistant' && m.html" class="markdown-body" v-html="m.html"></div> <div v-else-if="m.role === 'assistant' && streaming && i === messages.length - 1" class="assistant-running"> <span class="typing-dot"></span> <span>AI 正在生成回复...</span> </div> <div v-else class="plain-text" v-text="m.role === 'assistant' ? (m.md || '') : (m.text || '')"></div> </div> <div class="message-time">{{ m.ts }}</div> </div> </div> </template> </div> </section> <footer class="composer-panel"> <div class="composer-head"> <div><strong>向 AI 助手提问</strong></div> <div>{{ currentChatId ? '会话已绑定' : '临时会话' }}</div> </div> <el-input v-model="userInput" type="textarea" :autosize="{ minRows: 3, maxRows: 7 }" placeholder="例如:最近哪个设备最值得优先排查?异常是否和堆垛机任务、工位堵塞或日志波动有关?" :disabled="streaming" @keydown.native="handleComposerKeydown" ></el-input> <div class="composer-tools"> <div class="quick-actions" v-if="!streaming"> <button v-for="preset in inlinePrompts" :key="preset.title" @click="applyPreset(preset, true)"> {{ preset.title }} </button> </div> <div class="composer-actions"> <span class="composer-hint">Enter 发送,Shift+Enter 换行</span> <el-button type="primary" size="small" :disabled="sendDisabled" @click="ask">发送</el-button> </div> </div> </footer> </main> </div> </div> <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script> @@ -108,10 +654,11 @@ }); function getUserIconHtml(width, height) { width = width || 24; height = height || 24; return '<svg width="'+width+'" height="'+height+'" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">\n' + '<circle cx="12" cy="7" r="4" fill="#909399"/>\n' + '<path d="M4 20c0-4 4-6 8-6s8 2 8 6" fill="#909399" opacity="0.35"/>\n' width = width || 24; height = height || 24; return '<svg width="' + width + '" height="' + height + '" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' + '<circle cx="12" cy="7" r="4" fill="#6c7782"></circle>' + '<path d="M4 20c0-4 4-6 8-6s8 2 8 6" fill="#6c7782" opacity="0.34"></path>' + '</svg>'; } @@ -119,11 +666,12 @@ el: '#app', data: function() { return { headerIcon: getAiIconHtml(50, 50), headerIcon: getAiIconHtml(42, 42), assistantIcon: getAiIconHtml(24, 24), userIcon: getUserIconHtml(24, 24), loading: false, streaming: false, embedded: window !== window.top, source: null, messages: [], pendingText: '', @@ -135,67 +683,212 @@ autoScrollThreshold: 80, chats: [], currentChatId: '', resetting: false resetting: false, promptPresets: [ { title: '巡检当前系统', description: '让 AI 主动梳理设备、任务和日志,给出一轮完整巡检。', prompt: '' }, { title: '定位堆垛机异常', description: '结合近期日志与任务状态,判断是否存在堆垛机链路异常。', prompt: '帮我定位当前堆垛机相关的异常风险,按可能性从高到低列出。' }, { title: '分析堵塞与积压', description: '让 AI 优先关注工位堵塞、任务堆积和节拍异常。', prompt: '请重点分析当前是否存在工位堵塞、任务积压或节拍异常。' }, { title: '追问最近告警', description: '把最近异常事件压缩成可执行排查建议。', prompt: '帮我总结最近最值得关注的异常,并给出下一步排查动作。' } ] }; }, computed: { statusText: function() { if (this.streaming) return '诊断进行中'; if (this.streaming) return '诊断中'; if (this.loading) return '连接中'; return '空闲'; }, sendDisabled: function() { var t = (this.userInput || '').trim(); return this.streaming || t.length === 0; var text = (this.userInput || '').trim(); return this.streaming || text.length === 0; }, sortedChats: function() { var arr = Array.isArray(this.chats) ? this.chats.slice() : []; return arr.sort(function(a, b) { var at = a && a.updatedAt ? Number(a.updatedAt) : 0; var bt = b && b.updatedAt ? Number(b.updatedAt) : 0; return bt - at; }); }, currentChatSummary: function() { if (!this.currentChatId) return '未绑定历史会话'; var current = this.findChat(this.currentChatId); if (!current && this.resetting) return '新建会话,等待首条消息'; if (!current) return '会话 ' + this.currentChatId; return this.chatLabel(current); }, inlinePrompts: function() { return this.promptPresets.slice(1); } }, methods: { authHeaders: function() { var token = localStorage.getItem('token'); return token ? { token: token } : {}; }, notifyError: function(message) { if (this.$message && message) { this.$message.error(message); } }, closeDrawer: function() { if (!this.embedded) return; try { if (window.parent && window.parent.layer) { var index = window.parent.layer.getFrameIndex(window.name); if (typeof index === 'number' && index >= 0) { window.parent.layer.close(index); return; } } } catch (e) {} }, renderMarkdown: function(md, streaming) { if (!md) return ''; var src = md.replace(/\\n/g, '\n'); var source = md.replace(/\\n/g, '\n'); var openAttr = streaming ? ' open' : ''; src = src.replace(/<think>/g, '<details class="think-block"' + openAttr + '><summary>AI深度思考</summary><div class="content">'); src = src.replace(/<\/think>/g, '</div></details>'); if (streaming && src.indexOf('<details class="think-block"') >= 0 && src.indexOf('</div></details>') < 0) { src += '</div></details>'; source = source.replace(/<think>/g, '<details class="think-block"' + openAttr + '><summary>AI 深度思考</summary><div class="content">'); source = source.replace(/<\/think>/g, '</div></details>'); if (streaming && source.indexOf('<details class="think-block"') >= 0 && source.indexOf('</div></details>') < 0) { source += '</div></details>'; } return DOMPurify.sanitize(marked.parse(src)); return DOMPurify.sanitize(marked.parse(source)); }, loadChats: function() { loadChats: function(preferKeepCurrent) { var self = this; fetch(baseUrl + '/ai/diagnose/chats', { headers: { 'token': localStorage.getItem('token') } }) .then(function(r){ return r.json(); }) .then(function(arr){ if (Array.isArray(arr)) { self.chats = arr; } }); fetch(baseUrl + '/ai/diagnose/chats', { headers: self.authHeaders() }) .then(function(r) { return r.json(); }) .then(function(arr) { self.chats = Array.isArray(arr) ? arr : []; if (preferKeepCurrent && self.currentChatId) return; if (!self.currentChatId && self.sortedChats.length > 0) { self.openChat(self.sortedChats[0].chatId); return; } if (!self.currentChatId && self.sortedChats.length === 0) { self.newChat(); } }) .catch(function() { self.chats = []; if (!preferKeepCurrent) { self.newChat(); } self.notifyError('加载 AI 会话列表失败'); }); }, chatLabel: function(chat) { if (!chat) return '未命名会话'; if (chat.title) return chat.title; var id = chat.chatId || ''; return '会话 ' + (id.length > 8 ? id.slice(-8) : id); }, chatOptionLabel: function(chat) { if (!chat) return '未命名会话'; return this.chatLabel(chat) + ' · ' + (chat.size || 0) + ' 条 · ' + this.chatUpdatedAt(chat); }, chatUpdatedAt: function(chat) { if (!chat || !chat.updatedAt) return '刚刚创建'; var time = Number(chat.updatedAt); if (!time) return '最近更新'; var diff = Date.now() - time; if (diff < 60000) return '刚刚更新'; if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'; if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'; var date = new Date(time); return date.Format('MM-dd hh:mm'); }, findChat: function(chatId) { for (var i = 0; i < this.chats.length; i++) { if (this.chats[i] && this.chats[i].chatId === chatId) { return this.chats[i]; } } return null; }, openChat: function(chatId) { if (!chatId || this.streaming) return; this.currentChatId = chatId; this.switchChat(); }, switchChat: function() { var self = this; if (!self.currentChatId) { self.clear(); return; } fetch(baseUrl + '/ai/diagnose/chats/' + encodeURIComponent(self.currentChatId) + '/history', { headers: { 'token': localStorage.getItem('token') } }) .then(function(r){ return r.json(); }) .then(function(arr){ if (!self.currentChatId) { self.clear(); return; } fetch(baseUrl + '/ai/diagnose/chats/' + encodeURIComponent(self.currentChatId) + '/history', { headers: self.authHeaders() }) .then(function(r) { return r.json(); }) .then(function(arr) { if (!Array.isArray(arr)) return; var msgs = []; for (var i=0;i<arr.length;i++) { var m = arr[i]; if (m.role === 'assistant') msgs.push({ role: 'assistant', md: m.content || '', html: self.renderMarkdown(m.content || '', false), ts: self.nowStr() }); else msgs.push({ role: 'user', text: m.content || '', ts: self.nowStr() }); for (var i = 0; i < arr.length; i++) { var m = arr[i] || {}; if (m.role === 'assistant') { msgs.push({ role: 'assistant', md: m.content || '', html: self.renderMarkdown(m.content || '', false), ts: self.nowStr() }); } else { msgs.push({ role: 'user', text: m.content || '', ts: self.nowStr() }); } } self.messages = msgs; self.$nextTick(function(){ self.scrollToBottom(true); }); self.pendingText = ''; self.resetting = false; self.$nextTick(function() { self.scrollToBottom(true); }); }) .catch(function() { self.clear(); self.notifyError('加载会话历史失败'); }); }, newChat: function() { var id = Date.now() + '_' + Math.random().toString(36).substr(2,8); this.currentChatId = id; if (this.streaming) return; this.currentChatId = Date.now() + '_' + Math.random().toString(36).substr(2, 8); this.resetting = true; this.clear(); }, deleteChat: function() { var self = this; if (!self.currentChatId) return; fetch(baseUrl + '/ai/diagnose/chats/' + encodeURIComponent(self.currentChatId), { method: 'DELETE', headers: { 'token': localStorage.getItem('token') } }) .then(function(r){ return r.json(); }) .then(function(ok){ if (ok === true) { self.currentChatId = ''; self.clear(); self.loadChats(); self.newChat(); } }); if (!self.currentChatId || self.streaming) return; fetch(baseUrl + '/ai/diagnose/chats/' + encodeURIComponent(self.currentChatId), { method: 'DELETE', headers: self.authHeaders() }) .then(function(r) { return r.json(); }) .then(function(ok) { if (ok === true) { self.currentChatId = ''; self.clear(); self.loadChats(false); } }) .catch(function() { self.notifyError('删除会话失败'); }); }, shouldAutoScroll: function() { var el = this.$refs.chat; @@ -210,28 +903,43 @@ } }, nowStr: function() { var d = new Date(); function pad(n) { return (n<10?'0':'') + n; } var y = d.getFullYear(); var m = pad(d.getMonth() + 1); var day = pad(d.getDate()); var hh = pad(d.getHours()); var mm = pad(d.getMinutes()); var ss = pad(d.getSeconds()); return y + '-' + m + '-' + day + ' ' + hh + ':' + mm + ':' + ss; return new Date().Format('yyyy-MM-dd hh:mm:ss'); }, handleComposerKeydown: function(e) { if (!e) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.ask(); } }, applyPreset: function(preset, immediate) { if (this.streaming || !preset) return; if (!preset.prompt) { this.start(); return; } this.userInput = preset.prompt; if (immediate) { this.ask(); } }, appendAssistantPlaceholder: function() { this.messages.push({ role: 'assistant', md: '', html: '', ts: this.nowStr() }); }, ask: function() { if (this.streaming) return; var msg = (this.userInput || '').trim(); if (!msg) return; var message = (this.userInput || '').trim(); if (!message) return; this.loading = true; this.streaming = true; this.messages.push({ role: 'user', text: msg, ts: this.nowStr() }); this.messages.push({ role: 'assistant', md: '', html: '', ts: this.nowStr() }); this.messages.push({ role: 'user', text: message, ts: this.nowStr() }); this.appendAssistantPlaceholder(); this.scrollToBottom(true); var url = baseUrl + '/ai/diagnose/askStream?prompt=' + encodeURIComponent(msg); var url = baseUrl + '/ai/diagnose/askStream?prompt=' + encodeURIComponent(message); if (this.currentChatId) url += '&chatId=' + encodeURIComponent(this.currentChatId); if (this.resetting) url += '&reset=true'; this.source = new EventSource(url); var self = this; this.source.onopen = function() { @@ -258,20 +966,18 @@ this.clear(); this.loading = true; this.streaming = true; var url = baseUrl + '/ai/diagnose/runAiStream'; this.source = new EventSource(url); this.messages.push({ role: 'assistant', md: '', html: '', ts: this.nowStr() }); this.appendAssistantPlaceholder(); this.scrollToBottom(true); var self = this; this.source = new EventSource(baseUrl + '/ai/diagnose/runAiStream'); this.source.onopen = function() { self.loading = false; }; this.source.onmessage = function(e) { if (!e || !e.data) return; // 后端把换行转义成 \n,这里还原为真正的换行 var chunk = (e.data || '').replace(/\\n/g, '\n'); if (!chunk) return; // 如果仅包含换行符(如单独 \n 或 \n\n),丢弃避免空白行 var normalized = chunk.replace(/\r/g, ''); if (/^\n+$/.test(normalized)) return; self.pendingText += chunk; @@ -292,32 +998,32 @@ return; } if (self.pendingText.length > 0) { var n = Math.min(self.stepChars, self.pendingText.length); var part = self.pendingText.slice(0, n); self.pendingText = self.pendingText.slice(n); var last = self.messages.length > 0 ? self.messages[self.messages.length - 1] : null; var count = Math.min(self.stepChars, self.pendingText.length); var piece = self.pendingText.slice(0, count); self.pendingText = self.pendingText.slice(count); var last = self.messages.length ? self.messages[self.messages.length - 1] : null; if (last && last.role === 'assistant') { last.md = (last.md || '') + part; last.md = (last.md || '') + piece; } } var now = Date.now(); if (now - self.lastRenderTs > self.renderIntervalMs) { self.lastRenderTs = now; var last = self.messages.length > 0 ? self.messages[self.messages.length - 1] : null; if (last && last.role === 'assistant') { last.html = self.renderMarkdown(last.md || '', true); var latest = self.messages.length ? self.messages[self.messages.length - 1] : null; if (latest && latest.role === 'assistant') { latest.html = self.renderMarkdown(latest.md || '', true); self.$nextTick(function() { self.scrollToBottom(true); }); } } }, 50); }, stop: function() { stop: function(skipReload) { if (this.source) { this.source.close(); this.source = null; } var last = this.messages.length > 0 ? this.messages[this.messages.length - 1] : null; if (last && last.role === 'assistant' && this.pendingText && this.pendingText.length > 0) { var last = this.messages.length ? this.messages[this.messages.length - 1] : null; if (last && last.role === 'assistant' && this.pendingText) { last.md = (last.md || '') + this.pendingText; this.pendingText = ''; } @@ -331,16 +1037,20 @@ last.html = this.renderMarkdown(last.md || '', false); } this.$nextTick(function() { this.scrollToBottom(true); }.bind(this)); this.loadChats(); if (!skipReload) { this.loadChats(true); } }, clear: function() { this.messages = []; this.pendingText = ''; } } ,mounted: function() { this.loadChats(); this.newChat(); }, mounted: function() { this.loadChats(false); }, beforeDestroy: function() { this.stop(true); } }); </script>