#
Junjie
18 小时以前 8636ff97bffec9f2130628bf09c9d0fbb371e2bc
#
1个文件已添加
4个文件已修改
1872 ■■■■ 已修改文件
src/main/java/com/zy/ai/service/LlmChatService.java 417 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/LlmRoutingService.java 55 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/LlmSpringAiClientService.java 441 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/diagnosis.html 956 ●●●● 补丁 | 查看 | 原始文档 | 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());
                }
            }
        }, 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);
            }
        });
    }
    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));
    }
    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 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>
        <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="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" />
      <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>
        <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 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>
      <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">
            <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 v-html="assistantIcon"></span>
                <span>AI助手正在运行中</span>
                      <span class="typing-dot"></span>
                      <span>AI 正在生成回复...</span>
              </div>
              <div v-else v-text="m.text"></div>
              <div class="time">{{ m.ts }}</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>
      </el-card>
        </section>
      <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>
        <footer class="composer-panel">
          <div class="composer-head">
            <div><strong>向 AI 助手提问</strong></div>
            <div>{{ currentChatId ? '会话已绑定' : '临时会话' }}</div>
      </div>
    </el-card>
          <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') } })
          fetch(baseUrl + '/ai/diagnose/chats', { headers: self.authHeaders() })
            .then(function(r){ return r.json(); })
            .then(function(arr){ if (Array.isArray(arr)) { self.chats = arr; } });
            .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') } })
          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() });
                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.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') } })
          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(); self.newChat(); } });
            .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>