From 8636ff97bffec9f2130628bf09c9d0fbb371e2bc Mon Sep 17 00:00:00 2001
From: Junjie <fallin.jie@qq.com>
Date: 星期二, 10 三月 2026 16:53:24 +0800
Subject: [PATCH] #

---
 src/main/webapp/views/ai/diagnosis.html                       |  988 ++++++++++++++++++++++++++----
 src/main/java/com/zy/ai/service/LlmChatService.java           |  433 ------------
 src/main/java/com/zy/ai/service/LlmRoutingService.java        |   55 
 src/main/java/com/zy/ai/service/LlmSpringAiClientService.java |  441 +++++++++++++
 src/main/resources/application.yml                            |    3 
 5 files changed, 1,339 insertions(+), 581 deletions(-)

diff --git a/src/main/java/com/zy/ai/service/LlmChatService.java b/src/main/java/com/zy/ai/service/LlmChatService.java
index c92ede3..e2eddd6 100644
--- a/src/main/java/com/zy/ai/service/LlmChatService.java
+++ b/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("瑙f瀽 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 璺敱娴佸紓甯稿畬鎴愶紝鑷姩鍒囨崲锛宑urrent={}", 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;
         }
     }
 
diff --git a/src/main/java/com/zy/ai/service/LlmRoutingService.java b/src/main/java/com/zy/ai/service/LlmRoutingService.java
index 673babb..2af0825 100644
--- a/src/main/java/com/zy/ai/service/LlmRoutingService.java
+++ b/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) {
diff --git a/src/main/java/com/zy/ai/service/LlmSpringAiClientService.java b/src/main/java/com/zy/ai/service/LlmSpringAiClientService.java
new file mode 100644
index 0000000..746aa20
--- /dev/null
+++ b/src/main/java/com/zy/ai/service/LlmSpringAiClientService.java
@@ -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;
+        }
+    }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 5ffc018..6c3a42f 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -97,6 +97,9 @@
 llm:
   # 鐜板凡杩佺Щ鍒版暟鎹簱琛� sys_llm_route 缁存姢锛堟敮鎸佸API/澶氭ā鍨�/澶欿ey鑷姩鍒囨崲锛�
   # 浠ヤ笅浠呬綔涓烘暟鎹簱涓虹┖鏃剁殑鍏煎鍥為��閰嶇疆
+  # SpringAI 瀹㈡埛绔秴鏃堕厤缃�
+  connect-timeout-ms: 10000
+  read-timeout-ms: 12000
   thinking: false
   base-url:
   api-key:
diff --git a/src/main/webapp/views/ai/diagnosis.html b/src/main/webapp/views/ai/diagnosis.html
index b2dfd1e..70686ae 100644
--- a/src/main/webapp/views/ai/diagnosis.html
+++ b/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鍔╂墜姝e湪杩愯涓�</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 姝e湪鐢熸垚鍥炲...</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>

--
Gitblit v1.9.1