Junjie
昨天 825813e2dd90cf8bdc48acbb6eee85159bc33b4d
#AI LLM路由
7个文件已添加
3个文件已修改
1215 ■■■■■ 已修改文件
src/main/java/com/zy/ai/controller/LlmCallLogController.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/entity/LlmCallLog.java 219 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/mapper/LlmCallLogMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/LlmCallLogService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/LlmChatService.java 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/LlmRoutingService.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/LlmCallLogServiceImpl.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/LlmCallLogMapper.xml 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260303_create_sys_llm_call_log.sql 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/llm_config.html 623 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/controller/LlmCallLogController.java
New file
@@ -0,0 +1,65 @@
package com.zy.ai.controller;
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.baomidou.mybatisplus.plugins.Page;
import com.core.annotations.ManagerAuth;
import com.core.common.R;
import com.zy.ai.entity.LlmCallLog;
import com.zy.ai.service.LlmCallLogService;
import com.zy.common.web.BaseController;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/ai/llm/log")
@RequiredArgsConstructor
public class LlmCallLogController extends BaseController {
    private final LlmCallLogService llmCallLogService;
    @GetMapping("/list/auth")
    @ManagerAuth
    public R list(@RequestParam(defaultValue = "1") Integer curr,
                  @RequestParam(defaultValue = "20") Integer limit,
                  @RequestParam(required = false) String scene,
                  @RequestParam(required = false) Integer success,
                  @RequestParam(required = false) Long routeId,
                  @RequestParam(required = false) String traceId) {
        EntityWrapper<LlmCallLog> wrapper = new EntityWrapper<>();
        if (!isBlank(scene)) {
            wrapper.eq("scene", scene.trim());
        }
        if (success != null) {
            wrapper.eq("success", success);
        }
        if (routeId != null) {
            wrapper.eq("route_id", routeId);
        }
        if (!isBlank(traceId)) {
            wrapper.eq("trace_id", traceId.trim());
        }
        wrapper.orderBy("id", false);
        return R.ok(llmCallLogService.selectPage(new Page<>(curr, limit), wrapper));
    }
    @PostMapping("/delete/auth")
    @ManagerAuth
    public R delete(@RequestParam("id") Long id) {
        if (id == null) {
            return R.error("id不能为空");
        }
        llmCallLogService.deleteById(id);
        return R.ok();
    }
    @PostMapping("/clear/auth")
    @ManagerAuth
    public R clear() {
        llmCallLogService.delete(new EntityWrapper<LlmCallLog>());
        return R.ok();
    }
    private boolean isBlank(String s) {
        return s == null || s.trim().isEmpty();
    }
}
src/main/java/com/zy/ai/entity/LlmCallLog.java
New file
@@ -0,0 +1,219 @@
package com.zy.ai.entity;
import com.baomidou.mybatisplus.annotations.TableField;
import com.baomidou.mybatisplus.annotations.TableId;
import com.baomidou.mybatisplus.annotations.TableName;
import com.baomidou.mybatisplus.enums.IdType;
import java.io.Serializable;
import java.util.Date;
@TableName("sys_llm_call_log")
public class LlmCallLog implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @TableField("trace_id")
    private String traceId;
    private String scene;
    private Short stream;
    @TableField("attempt_no")
    private Integer attemptNo;
    @TableField("route_id")
    private Long routeId;
    @TableField("route_name")
    private String routeName;
    @TableField("base_url")
    private String baseUrl;
    private String model;
    private Short success;
    @TableField("http_status")
    private Integer httpStatus;
    @TableField("latency_ms")
    private Long latencyMs;
    @TableField("switch_mode")
    private String switchMode;
    @TableField("request_content")
    private String requestContent;
    @TableField("response_content")
    private String responseContent;
    @TableField("error_type")
    private String errorType;
    @TableField("error_message")
    private String errorMessage;
    private String extra;
    @TableField("create_time")
    private Date createTime;
    public Long getId() {
        return id;
    }
    public void setId(Long id) {
        this.id = id;
    }
    public String getTraceId() {
        return traceId;
    }
    public void setTraceId(String traceId) {
        this.traceId = traceId;
    }
    public String getScene() {
        return scene;
    }
    public void setScene(String scene) {
        this.scene = scene;
    }
    public Short getStream() {
        return stream;
    }
    public void setStream(Short stream) {
        this.stream = stream;
    }
    public Integer getAttemptNo() {
        return attemptNo;
    }
    public void setAttemptNo(Integer attemptNo) {
        this.attemptNo = attemptNo;
    }
    public Long getRouteId() {
        return routeId;
    }
    public void setRouteId(Long routeId) {
        this.routeId = routeId;
    }
    public String getRouteName() {
        return routeName;
    }
    public void setRouteName(String routeName) {
        this.routeName = routeName;
    }
    public String getBaseUrl() {
        return baseUrl;
    }
    public void setBaseUrl(String baseUrl) {
        this.baseUrl = baseUrl;
    }
    public String getModel() {
        return model;
    }
    public void setModel(String model) {
        this.model = model;
    }
    public Short getSuccess() {
        return success;
    }
    public void setSuccess(Short success) {
        this.success = success;
    }
    public Integer getHttpStatus() {
        return httpStatus;
    }
    public void setHttpStatus(Integer httpStatus) {
        this.httpStatus = httpStatus;
    }
    public Long getLatencyMs() {
        return latencyMs;
    }
    public void setLatencyMs(Long latencyMs) {
        this.latencyMs = latencyMs;
    }
    public String getSwitchMode() {
        return switchMode;
    }
    public void setSwitchMode(String switchMode) {
        this.switchMode = switchMode;
    }
    public String getRequestContent() {
        return requestContent;
    }
    public void setRequestContent(String requestContent) {
        this.requestContent = requestContent;
    }
    public String getResponseContent() {
        return responseContent;
    }
    public void setResponseContent(String responseContent) {
        this.responseContent = responseContent;
    }
    public String getErrorType() {
        return errorType;
    }
    public void setErrorType(String errorType) {
        this.errorType = errorType;
    }
    public String getErrorMessage() {
        return errorMessage;
    }
    public void setErrorMessage(String errorMessage) {
        this.errorMessage = errorMessage;
    }
    public String getExtra() {
        return extra;
    }
    public void setExtra(String extra) {
        this.extra = extra;
    }
    public Date getCreateTime() {
        return createTime;
    }
    public void setCreateTime(Date createTime) {
        this.createTime = createTime;
    }
}
src/main/java/com/zy/ai/mapper/LlmCallLogMapper.java
New file
@@ -0,0 +1,11 @@
package com.zy.ai.mapper;
import com.baomidou.mybatisplus.mapper.BaseMapper;
import com.zy.ai.entity.LlmCallLog;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface LlmCallLogMapper extends BaseMapper<LlmCallLog> {
}
src/main/java/com/zy/ai/service/LlmCallLogService.java
New file
@@ -0,0 +1,8 @@
package com.zy.ai.service;
import com.baomidou.mybatisplus.service.IService;
import com.zy.ai.entity.LlmCallLog;
public interface LlmCallLogService extends IService<LlmCallLog> {
    void saveIgnoreError(LlmCallLog log);
}
src/main/java/com/zy/ai/service/LlmChatService.java
@@ -5,6 +5,7 @@
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;
@@ -16,7 +17,9 @@
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -27,7 +30,10 @@
@RequiredArgsConstructor
public class LlmChatService {
    private static final int LOG_TEXT_LIMIT = 16000;
    private final LlmRoutingService llmRoutingService;
    private final LlmCallLogService llmCallLogService;
    @Value("${llm.base-url:}")
    private String fallbackBaseUrl;
@@ -54,7 +60,7 @@
        req.setMax_tokens(maxTokens != null ? maxTokens : 1024);
        req.setStream(false);
        ChatCompletionResponse response = complete(req);
        ChatCompletionResponse response = complete(req, "chat");
        if (response == null ||
                response.getChoices() == null ||
@@ -81,13 +87,20 @@
            req.setTools(tools);
            req.setTool_choice("auto");
        }
        return complete(req);
        return complete(req, tools != null && !tools.isEmpty() ? "chat_completion_tools" : "chat_completion");
    }
    public ChatCompletionResponse complete(ChatCompletionRequest req) {
        return complete(req, "completion");
    }
    private ChatCompletionResponse complete(ChatCompletionRequest req, String scene) {
        String traceId = nextTraceId();
        List<ResolvedRoute> routes = resolveRoutes();
        if (routes.isEmpty()) {
            log.error("调用 LLM 失败: 未配置可用 LLM 路由");
            recordCall(traceId, scene, false, 1, null, false, null, 0L, req, null, "none",
                    new RuntimeException("未配置可用 LLM 路由"), "no_route");
            return null;
        }
@@ -95,19 +108,39 @@
        for (int i = 0; i < routes.size(); i++) {
            ResolvedRoute route = routes.get(i);
            boolean hasNext = i < routes.size() - 1;
            ChatCompletionRequest routeReq = applyRoute(cloneRequest(req), route, false);
            long start = System.currentTimeMillis();
            try {
                ChatCompletionRequest routeReq = applyRoute(cloneRequest(req), route, false);
                ChatCompletionResponse resp = callCompletion(route, routeReq);
                CompletionCallResult callResult = callCompletion(route, routeReq);
                ChatCompletionResponse resp = callResult.response;
                if (!isValidCompletion(resp)) {
                    throw new RuntimeException("LLM 响应为空");
                    RuntimeException ex = new RuntimeException("LLM 响应为空");
                    boolean canSwitch = shouldSwitch(route, false);
                    markFailure(route, ex, canSwitch);
                    recordCall(traceId, scene, false, i + 1, route, false, callResult.statusCode,
                            System.currentTimeMillis() - start, routeReq, callResult.payload, "error", ex,
                            "invalid_completion");
                    if (hasNext && canSwitch) {
                        log.warn("LLM 切换到下一路由, current={}, reason={}", route.tag(), ex.getMessage());
                        continue;
                    }
                    log.error("调用 LLM 失败, route={}", route.tag(), ex);
                    last = ex;
                    break;
                }
                markSuccess(route);
                recordCall(traceId, scene, false, i + 1, route, true, callResult.statusCode,
                        System.currentTimeMillis() - start, routeReq, buildResponseText(resp, callResult.payload),
                        "none", null, null);
                return resp;
            } catch (Throwable ex) {
                last = ex;
                boolean quota = isQuotaExhausted(ex);
                boolean canSwitch = shouldSwitch(route, quota);
                markFailure(route, ex, canSwitch);
                recordCall(traceId, scene, false, i + 1, route, false, statusCodeOf(ex),
                        System.currentTimeMillis() - start, routeReq, responseBodyOf(ex),
                        quota ? "quota" : "error", ex, null);
                if (hasNext && canSwitch) {
                    log.warn("LLM 切换到下一路由, current={}, reason={}", route.tag(), errorText(ex));
                    continue;
@@ -136,7 +169,7 @@
        req.setMax_tokens(maxTokens != null ? maxTokens : 1024);
        req.setStream(true);
        streamWithFailover(req, onChunk, onComplete, onError);
        streamWithFailover(req, onChunk, onComplete, onError, "chat_stream");
    }
    public void chatStreamWithTools(List<ChatCompletionRequest.Message> messages,
@@ -155,19 +188,23 @@
            req.setTools(tools);
            req.setTool_choice("auto");
        }
        streamWithFailover(req, onChunk, onComplete, onError);
        streamWithFailover(req, onChunk, onComplete, onError, tools != null && !tools.isEmpty() ? "chat_stream_tools" : "chat_stream");
    }
    private void streamWithFailover(ChatCompletionRequest req,
                                    Consumer<String> onChunk,
                                    Runnable onComplete,
                                    Consumer<Throwable> onError) {
                                    Consumer<Throwable> onError,
                                    String scene) {
        String traceId = nextTraceId();
        List<ResolvedRoute> routes = resolveRoutes();
        if (routes.isEmpty()) {
            recordCall(traceId, scene, true, 1, null, false, null, 0L, req, null, "none",
                    new RuntimeException("未配置可用 LLM 路由"), "no_route");
            if (onError != null) onError.accept(new RuntimeException("未配置可用 LLM 路由"));
            return;
        }
        attemptStream(routes, 0, req, onChunk, onComplete, onError);
        attemptStream(routes, 0, req, onChunk, onComplete, onError, traceId, scene);
    }
    private void attemptStream(List<ResolvedRoute> routes,
@@ -175,7 +212,9 @@
                               ChatCompletionRequest req,
                               Consumer<String> onChunk,
                               Runnable onComplete,
                               Consumer<Throwable> onError) {
                               Consumer<Throwable> onError,
                               String traceId,
                               String scene) {
        if (index >= routes.size()) {
            if (onError != null) onError.accept(new RuntimeException("LLM 路由全部失败"));
            return;
@@ -183,6 +222,8 @@
        ResolvedRoute route = routes.get(index);
        ChatCompletionRequest routeReq = applyRoute(cloneRequest(req), route, true);
        long start = System.currentTimeMillis();
        StringBuilder outputBuffer = new StringBuilder();
        AtomicBoolean doneSeen = new AtomicBoolean(false);
        AtomicBoolean errorSeen = new AtomicBoolean(false);
@@ -240,6 +281,7 @@
                            String content = delta.getString("content");
                            if (content != null) {
                                queue.offer(content);
                                appendLimited(outputBuffer, content);
                            }
                        }
                    }
@@ -253,9 +295,12 @@
            boolean quota = isQuotaExhausted(err);
            boolean canSwitch = shouldSwitch(route, quota);
            markFailure(route, err, canSwitch);
            recordCall(traceId, scene, true, index + 1, route, false, statusCodeOf(err),
                    System.currentTimeMillis() - start, routeReq, outputBuffer.toString(),
                    quota ? "quota" : "error", err, "emitted=" + emitted.get());
            if (!emitted.get() && canSwitch && index < routes.size() - 1) {
                log.warn("LLM 路由失败,自动切换,current={}, reason={}", route.tag(), errorText(err));
                attemptStream(routes, index + 1, req, onChunk, onComplete, onError);
                attemptStream(routes, index + 1, req, onChunk, onComplete, onError, traceId, scene);
                return;
            }
            if (onError != null) onError.accept(err);
@@ -266,14 +311,20 @@
                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);
                    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);
            }
        });
@@ -299,7 +350,7 @@
                .doOnError(ex -> log.error("调用 LLM 流式失败, route={}", route.tag(), ex));
    }
    private ChatCompletionResponse callCompletion(ResolvedRoute route, ChatCompletionRequest req) {
    private CompletionCallResult callCompletion(ResolvedRoute route, ChatCompletionRequest req) {
        WebClient client = WebClient.builder().baseUrl(route.baseUrl).build();
        RawCompletionResult raw = client.post()
                .uri("/chat/completions")
@@ -318,7 +369,7 @@
        if (raw.statusCode < 200 || raw.statusCode >= 300) {
            throw new LlmRouteException(raw.statusCode, raw.payload);
        }
        return parseCompletion(raw.payload);
        return new CompletionCallResult(raw.statusCode, raw.payload, parseCompletion(raw.payload));
    }
    private ChatCompletionRequest applyRoute(ChatCompletionRequest req, ResolvedRoute route, boolean stream) {
@@ -517,6 +568,112 @@
        return r;
    }
    private String nextTraceId() {
        return UUID.randomUUID().toString().replace("-", "");
    }
    private void appendLimited(StringBuilder sb, String text) {
        if (sb == null || text == null || text.isEmpty()) {
            return;
        }
        int remain = LOG_TEXT_LIMIT - sb.length();
        if (remain <= 0) {
            return;
        }
        if (text.length() <= remain) {
            sb.append(text);
        } else {
            sb.append(text, 0, remain);
        }
    }
    private Integer statusCodeOf(Throwable ex) {
        if (ex instanceof LlmRouteException) {
            return ((LlmRouteException) ex).statusCode;
        }
        return null;
    }
    private String responseBodyOf(Throwable ex) {
        if (ex instanceof LlmRouteException) {
            return cut(((LlmRouteException) ex).body, LOG_TEXT_LIMIT);
        }
        return null;
    }
    private String buildResponseText(ChatCompletionResponse resp, String fallbackPayload) {
        if (resp != null && resp.getChoices() != null && !resp.getChoices().isEmpty()
                && resp.getChoices().get(0) != null && resp.getChoices().get(0).getMessage() != null) {
            ChatCompletionRequest.Message m = resp.getChoices().get(0).getMessage();
            if (!isBlank(m.getContent())) {
                return cut(m.getContent(), LOG_TEXT_LIMIT);
            }
            if (m.getTool_calls() != null && !m.getTool_calls().isEmpty()) {
                return cut(JSON.toJSONString(m), LOG_TEXT_LIMIT);
            }
        }
        return cut(fallbackPayload, LOG_TEXT_LIMIT);
    }
    private String safeName(Throwable ex) {
        return ex == null ? null : ex.getClass().getSimpleName();
    }
    private String cut(String text, int maxLen) {
        if (text == null) return null;
        String clean = text.replace("\r", " ");
        return clean.length() > maxLen ? clean.substring(0, maxLen) : clean;
    }
    private void recordCall(String traceId,
                            String scene,
                            boolean stream,
                            int attemptNo,
                            ResolvedRoute route,
                            boolean success,
                            Integer httpStatus,
                            long latencyMs,
                            ChatCompletionRequest req,
                            String response,
                            String switchMode,
                            Throwable err,
                            String extra) {
        LlmCallLog item = new LlmCallLog();
        item.setTraceId(cut(traceId, 64));
        item.setScene(cut(scene, 64));
        item.setStream((short) (stream ? 1 : 0));
        item.setAttemptNo(attemptNo);
        if (route != null) {
            item.setRouteId(route.id);
            item.setRouteName(cut(route.name, 128));
            item.setBaseUrl(cut(route.baseUrl, 255));
            item.setModel(cut(route.model, 128));
        }
        item.setSuccess((short) (success ? 1 : 0));
        item.setHttpStatus(httpStatus);
        item.setLatencyMs(latencyMs < 0 ? 0 : latencyMs);
        item.setSwitchMode(cut(switchMode, 32));
        item.setRequestContent(cut(JSON.toJSONString(req), LOG_TEXT_LIMIT));
        item.setResponseContent(cut(response, LOG_TEXT_LIMIT));
        item.setErrorType(cut(safeName(err), 128));
        item.setErrorMessage(err == null ? null : cut(errorText(err), 1024));
        item.setExtra(cut(extra, 512));
        item.setCreateTime(new Date());
        llmCallLogService.saveIgnoreError(item);
    }
    private static class CompletionCallResult {
        private final int statusCode;
        private final String payload;
        private final ChatCompletionResponse response;
        private CompletionCallResult(int statusCode, String payload, ChatCompletionResponse response) {
            this.statusCode = statusCode;
            this.payload = payload;
            this.response = response;
        }
    }
    private static class RawCompletionResult {
        private final int statusCode;
        private final String payload;
src/main/java/com/zy/ai/service/LlmRoutingService.java
@@ -12,6 +12,7 @@
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
@@ -29,6 +30,14 @@
    private volatile List<LlmRouteConfig> allRouteCache = Collections.emptyList();
    private volatile long cacheExpireAt = 0L;
    private static final Comparator<LlmRouteConfig> ROUTE_ORDER = (a, b) -> {
        int pa = a == null || a.getPriority() == null ? Integer.MAX_VALUE : a.getPriority();
        int pb = b == null || b.getPriority() == null ? Integer.MAX_VALUE : b.getPriority();
        if (pa != pb) return Integer.compare(pa, pb);
        long ia = a == null || a.getId() == null ? Long.MAX_VALUE : a.getId();
        long ib = b == null || b.getId() == null ? Long.MAX_VALUE : b.getId();
        return Long.compare(ia, ib);
    };
    public void evictCache() {
        cacheExpireAt = 0L;
@@ -63,9 +72,11 @@
        }
        if (result.isEmpty() && !coolingRoutes.isEmpty()) {
            // 避免所有路由都处于冷却时系统完全不可用,降级允许使用冷却路由
            coolingRoutes.sort(ROUTE_ORDER);
            log.warn("LLM 路由均处于冷却,降级启用冷却路由。cooling={}, total={}", coolingRoutes.size(), total);
            return coolingRoutes;
        }
        result.sort(ROUTE_ORDER);
        if (result.isEmpty()) {
            log.warn("未找到可用 LLM 路由。total={}, disabled={}, invalid={}", total, disabled, invalid);
        }
@@ -147,7 +158,12 @@
            EntityWrapper<LlmRouteConfig> wrapper = new EntityWrapper<>();
            wrapper.orderBy("priority", true).orderBy("id", true);
            List<LlmRouteConfig> list = llmRouteConfigService.selectList(wrapper);
            allRouteCache = list == null ? Collections.emptyList() : list;
            if (list == null) {
                allRouteCache = Collections.emptyList();
            } else {
                list.sort(ROUTE_ORDER);
                allRouteCache = list;
            }
            cacheExpireAt = System.currentTimeMillis() + CACHE_TTL_MS;
            return allRouteCache;
        }
src/main/java/com/zy/ai/service/impl/LlmCallLogServiceImpl.java
New file
@@ -0,0 +1,33 @@
package com.zy.ai.service.impl;
import com.baomidou.mybatisplus.service.impl.ServiceImpl;
import com.zy.ai.entity.LlmCallLog;
import com.zy.ai.mapper.LlmCallLogMapper;
import com.zy.ai.service.LlmCallLogService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service("llmCallLogService")
@Slf4j
public class LlmCallLogServiceImpl extends ServiceImpl<LlmCallLogMapper, LlmCallLog> implements LlmCallLogService {
    private volatile boolean disabled = false;
    @Override
    public void saveIgnoreError(LlmCallLog logItem) {
        if (logItem == null || disabled) {
            return;
        }
        try {
            insert(logItem);
        } catch (Exception e) {
            String msg = e.getMessage() == null ? "" : e.getMessage();
            if (msg.contains("doesn't exist") || msg.contains("不存在")) {
                disabled = true;
                log.warn("LLM调用日志表不存在,日志记录已自动关闭,请先执行建表SQL");
                return;
            }
            log.warn("写入LLM调用日志失败: {}", msg);
        }
    }
}
src/main/resources/mapper/LlmCallLogMapper.xml
New file
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zy.ai.mapper.LlmCallLogMapper">
    <resultMap id="BaseResultMap" type="com.zy.ai.entity.LlmCallLog">
        <id column="id" property="id"/>
        <result column="trace_id" property="traceId"/>
        <result column="scene" property="scene"/>
        <result column="stream" property="stream"/>
        <result column="attempt_no" property="attemptNo"/>
        <result column="route_id" property="routeId"/>
        <result column="route_name" property="routeName"/>
        <result column="base_url" property="baseUrl"/>
        <result column="model" property="model"/>
        <result column="success" property="success"/>
        <result column="http_status" property="httpStatus"/>
        <result column="latency_ms" property="latencyMs"/>
        <result column="switch_mode" property="switchMode"/>
        <result column="request_content" property="requestContent"/>
        <result column="response_content" property="responseContent"/>
        <result column="error_type" property="errorType"/>
        <result column="error_message" property="errorMessage"/>
        <result column="extra" property="extra"/>
        <result column="create_time" property="createTime"/>
    </resultMap>
</mapper>
src/main/resources/sql/20260303_create_sys_llm_call_log.sql
New file
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS `sys_llm_call_log` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
  `trace_id` VARCHAR(64) NOT NULL COMMENT '一次调用链路ID',
  `scene` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '调用场景',
  `stream` TINYINT NOT NULL DEFAULT 0 COMMENT '是否流式:1是0否',
  `attempt_no` INT NOT NULL DEFAULT 1 COMMENT '第几次路由尝试',
  `route_id` BIGINT DEFAULT NULL COMMENT '路由ID',
  `route_name` VARCHAR(128) DEFAULT NULL COMMENT '路由名称',
  `base_url` VARCHAR(255) DEFAULT NULL COMMENT '请求API地址',
  `model` VARCHAR(128) DEFAULT NULL COMMENT '模型名',
  `success` TINYINT NOT NULL DEFAULT 0 COMMENT '是否成功:1是0否',
  `http_status` INT DEFAULT NULL COMMENT 'HTTP状态码',
  `latency_ms` BIGINT DEFAULT NULL COMMENT '耗时ms',
  `switch_mode` VARCHAR(32) DEFAULT NULL COMMENT '切换触发类型:none/quota/error',
  `request_content` MEDIUMTEXT COMMENT '请求内容(截断)',
  `response_content` MEDIUMTEXT COMMENT '响应内容(截断)',
  `error_type` VARCHAR(128) DEFAULT NULL COMMENT '异常类型',
  `error_message` VARCHAR(1024) DEFAULT NULL COMMENT '异常信息',
  `extra` VARCHAR(512) DEFAULT NULL COMMENT '扩展信息',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_sys_llm_call_log_trace` (`trace_id`),
  KEY `idx_sys_llm_call_log_scene_time` (`scene`, `create_time`),
  KEY `idx_sys_llm_call_log_route_time` (`route_id`, `create_time`),
  KEY `idx_sys_llm_call_log_success_time` (`success`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='LLM调用日志';
src/main/webapp/views/ai/llm_config.html
@@ -8,6 +8,7 @@
  <style>
    body {
      margin: 0;
      font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
      background:
        radial-gradient(1200px 500px at 10% -10%, rgba(26, 115, 232, 0.14), transparent 50%),
        radial-gradient(900px 450px at 100% 0%, rgba(38, 166, 154, 0.11), transparent 55%),
@@ -70,12 +71,171 @@
      font-weight: 700;
      line-height: 1.1;
    }
    .table-shell {
      border-radius: 12px;
    .route-board {
      border-radius: 14px;
      border: 1px solid #dbe5f2;
      background:
        radial-gradient(800px 200px at -10% 0, rgba(52, 119, 201, 0.06), transparent 55%),
        radial-gradient(700px 220px at 110% 20%, rgba(39, 154, 136, 0.08), transparent 58%),
        #f9fbff;
      box-shadow: 0 8px 30px rgba(26, 53, 84, 0.10);
      padding: 12px;
      min-height: 64vh;
    }
    .route-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(390px, 1fr));
      gap: 12px;
    }
    .route-card {
      border-radius: 14px;
      border: 1px solid #e4ebf5;
      background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
      box-shadow: 0 10px 24px rgba(14, 38, 68, 0.08);
      padding: 12px;
      transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
      animation: card-in 0.24s ease both;
    }
    .route-card:hover {
      transform: translateY(-2px);
      box-shadow: 0 14px 26px rgba(14, 38, 68, 0.12);
      border-color: #d4e2f2;
    }
    .route-card.cooling {
      border-color: #f2d8a2;
      background: linear-gradient(180deg, #fffdf6 0%, #fffaf0 100%);
    }
    .route-card.disabled {
      opacity: 0.84;
    }
    .route-head {
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 8px;
      margin-bottom: 10px;
    }
    .route-title {
      display: flex;
      flex-direction: column;
      gap: 5px;
      min-width: 0;
      flex: 1;
    }
    .route-id-line {
      color: #8294aa;
      font-size: 11px;
      white-space: nowrap;
      overflow: hidden;
      box-shadow: 0 6px 22px rgba(15, 28, 48, 0.08);
      border: 1px solid #e8edf5;
      text-overflow: ellipsis;
    }
    .route-state {
      display: flex;
      gap: 6px;
      align-items: center;
      flex-wrap: wrap;
      justify-content: flex-end;
      max-width: 46%;
    }
    .route-fields {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 8px;
    }
    .field-full {
      grid-column: 1 / -1;
    }
    .field-label {
      font-size: 11px;
      color: #6f8094;
      margin-bottom: 4px;
    }
    .switch-line {
      margin-top: 10px;
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 8px;
    }
    .switch-item {
      border: 1px solid #e7edf7;
      border-radius: 10px;
      padding: 6px 8px;
      background: #fff;
      display: flex;
      align-items: center;
      justify-content: space-between;
      font-size: 12px;
      color: #2f3f53;
    }
    .stats-box {
      margin-top: 10px;
      border: 1px solid #e8edf6;
      border-radius: 10px;
      background: linear-gradient(180deg, #fcfdff 0%, #f7faff 100%);
      padding: 8px 10px;
      font-size: 12px;
      color: #4c5f76;
      line-height: 1.6;
    }
    .stats-box .light {
      color: #7f91a8;
    }
    .route-actions {
      margin-top: 10px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
    }
    .action-left, .action-right {
      display: flex;
      align-items: center;
      gap: 8px;
    }
    .empty-shell {
      min-height: 48vh;
      border-radius: 12px;
      border: 1px dashed #cfd8e5;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      color: #7d8ea4;
      gap: 8px;
      background: rgba(255, 255, 255, 0.55);
    }
    .log-toolbar {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 10px;
      flex-wrap: wrap;
    }
    .log-text {
      max-width: 360px;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      color: #6c7f95;
      font-size: 12px;
    }
    .log-detail-body {
      max-height: 62vh;
      overflow: auto;
      border: 1px solid #dfe8f3;
      border-radius: 8px;
      background: #f8fbff;
      padding: 10px 12px;
      white-space: pre-wrap;
      word-break: break-word;
      line-height: 1.55;
      color: #2e3c4f;
      font-size: 12px;
      font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
    }
    @keyframes card-in {
      from { opacity: 0; transform: translateY(8px); }
      to { opacity: 1; transform: translateY(0); }
    }
    .mono {
      font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
@@ -83,6 +243,9 @@
    }
    @media (max-width: 1280px) {
      .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
      .route-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
      .route-fields { grid-template-columns: 1fr; }
      .switch-line { grid-template-columns: 1fr; }
    }
  </style>
</head>
@@ -100,6 +263,7 @@
      <div>
        <el-button type="primary" size="mini" @click="addRoute">新增路由</el-button>
        <el-button size="mini" @click="loadRoutes">刷新</el-button>
        <el-button size="mini" @click="openLogDialog">调用日志</el-button>
      </div>
    </div>
    <div class="summary-grid">
@@ -126,95 +290,170 @@
    </div>
  </div>
  <div class="table-shell">
    <el-table :data="routes" stripe height="72vh" v-loading="loading" :header-cell-style="{background:'#f7f9fc', color:'#2e3a4d', fontWeight:600}">
      <el-table-column label="名称" width="170">
        <template slot-scope="scope">
          <el-input v-model="scope.row.name" size="mini"></el-input>
        </template>
      </el-table-column>
  <div class="route-board" v-loading="loading">
    <div v-if="!routes || routes.length === 0" class="empty-shell">
      <div style="font-size:14px;font-weight:600;">暂无路由配置</div>
      <div style="font-size:12px;">点击右上角“新增路由”创建第一条配置</div>
    </div>
    <div v-else class="route-grid">
      <div class="route-card" :class="routeCardClass(route)" v-for="(route, idx) in routes" :key="route.id ? ('route_' + route.id) : ('new_' + idx)">
        <div class="route-head">
          <div class="route-title">
            <el-input v-model="route.name" size="mini" placeholder="路由名称"></el-input>
            <div class="route-id-line">#{{ route.id || 'new' }} · 优先级 {{ route.priority || 0 }}</div>
          </div>
          <div class="route-state">
            <el-tag size="mini" :type="route.status === 1 ? 'success' : 'info'">{{ route.status === 1 ? '启用' : '禁用' }}</el-tag>
            <el-tag size="mini" type="warning" v-if="isRouteCooling(route)">冷却中</el-tag>
          </div>
        </div>
      <el-table-column label="Base URL" min-width="220">
        <template slot-scope="scope">
          <el-input v-model="scope.row.baseUrl" class="mono" size="mini" placeholder="必填,例如: https://api.deepseek.com"></el-input>
        </template>
      </el-table-column>
        <div class="route-fields">
          <div class="field-full">
            <div class="field-label">Base URL</div>
            <el-input v-model="route.baseUrl" class="mono" size="mini" placeholder="必填,例如: https://dashscope.aliyuncs.com/compatible-mode/v1"></el-input>
          </div>
          <div>
            <div class="field-label">模型</div>
            <el-input v-model="route.model" class="mono" size="mini" placeholder="必填"></el-input>
          </div>
          <div>
            <div class="field-label">优先级(越小越优先)</div>
            <el-input-number v-model="route.priority" size="mini" :min="0" :max="99999" :controls="false" style="width:100%;"></el-input-number>
          </div>
          <div class="field-full">
            <div class="field-label">API Key</div>
            <el-input v-model="route.apiKey" class="mono" type="password" size="mini" placeholder="必填">
              <template slot="append">
                <el-button type="text" style="padding:0 8px;" @click="copyApiKey(route)">复制</el-button>
              </template>
            </el-input>
          </div>
          <div>
            <div class="field-label">冷却秒数</div>
            <el-input-number v-model="route.cooldownSeconds" size="mini" :min="0" :max="86400" :controls="false" style="width:100%;"></el-input-number>
          </div>
        </div>
      <el-table-column label="模型" width="180">
        <template slot-scope="scope">
          <el-input v-model="scope.row.model" class="mono" size="mini" placeholder="必填,例如: deepseek-chat"></el-input>
        </template>
      </el-table-column>
        <div class="switch-line">
          <div class="switch-item">
            <span>状态</span>
            <el-switch v-model="route.status" :active-value="1" :inactive-value="0"></el-switch>
          </div>
          <div class="switch-item">
            <span>思考</span>
            <el-switch v-model="route.thinking" :active-value="1" :inactive-value="0"></el-switch>
          </div>
          <div class="switch-item">
            <span>额度切换</span>
            <el-switch v-model="route.switchOnQuota" :active-value="1" :inactive-value="0"></el-switch>
          </div>
          <div class="switch-item">
            <span>故障切换</span>
            <el-switch v-model="route.switchOnError" :active-value="1" :inactive-value="0"></el-switch>
          </div>
        </div>
      <el-table-column label="API Key" min-width="220">
        <template slot-scope="scope">
          <el-input v-model="scope.row.apiKey" class="mono" type="password" size="mini" placeholder="必填"></el-input>
        </template>
      </el-table-column>
        <div class="stats-box">
          <div>成功 {{ route.successCount || 0 }} / 失败 {{ route.failCount || 0 }} / 连续失败 {{ route.consecutiveFailCount || 0 }}</div>
          <div class="light">冷却到: {{ formatDateTime(route.cooldownUntil) }}</div>
          <div class="light">最近错误: {{ route.lastError || '-' }}</div>
        </div>
      <el-table-column label="优先级" width="90">
        <template slot-scope="scope">
          <el-input-number v-model="scope.row.priority" size="mini" :min="0" :max="99999" :controls="false" style="width:80px;"></el-input-number>
        </template>
      </el-table-column>
      <el-table-column label="状态" width="70">
        <template slot-scope="scope">
          <el-switch v-model="scope.row.status" :active-value="1" :inactive-value="0"></el-switch>
        </template>
      </el-table-column>
      <el-table-column label="思考" width="70">
        <template slot-scope="scope">
          <el-switch v-model="scope.row.thinking" :active-value="1" :inactive-value="0"></el-switch>
        </template>
      </el-table-column>
      <el-table-column label="额度切换" width="90">
        <template slot-scope="scope">
          <el-switch v-model="scope.row.switchOnQuota" :active-value="1" :inactive-value="0"></el-switch>
        </template>
      </el-table-column>
      <el-table-column label="故障切换" width="90">
        <template slot-scope="scope">
          <el-switch v-model="scope.row.switchOnError" :active-value="1" :inactive-value="0"></el-switch>
        </template>
      </el-table-column>
      <el-table-column label="冷却秒数" width="100">
        <template slot-scope="scope">
          <el-input-number v-model="scope.row.cooldownSeconds" size="mini" :min="0" :max="86400" :controls="false" style="width:90px;"></el-input-number>
        </template>
      </el-table-column>
      <el-table-column label="统计" min-width="220">
        <template slot-scope="scope">
          <div>成功: {{ scope.row.successCount || 0 }} / 失败: {{ scope.row.failCount || 0 }} / 连续失败: {{ scope.row.consecutiveFailCount || 0 }}</div>
          <div style="color:#909399;">冷却到: {{ scope.row.cooldownUntil || '-' }}</div>
          <div style="color:#909399;">最近错误: {{ scope.row.lastError || '-' }}</div>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="120" fixed="right" align="center">
        <template slot-scope="scope">
          <el-dropdown trigger="click" @command="function(cmd){ handleRouteCommand(cmd, scope.row, scope.$index); }">
            <el-button size="mini" type="primary" plain>
              操作<i class="el-icon-arrow-down el-icon--right"></i>
        <div class="route-actions">
          <div class="action-left">
            <el-button type="primary" size="mini" @click="saveRoute(route)">保存</el-button>
            <el-button size="mini" :loading="route.__testing === true" @click="testRoute(route)">
              {{ route.__testing === true ? '测试中...' : '测试' }}
            </el-button>
            <el-dropdown-menu slot="dropdown">
              <el-dropdown-item command="test" :disabled="scope.row.__testing === true">
                {{ scope.row.__testing === true ? '测试中...' : '测试' }}
              </el-dropdown-item>
              <el-dropdown-item command="save">保存</el-dropdown-item>
              <el-dropdown-item command="cooldown">清冷却</el-dropdown-item>
              <el-dropdown-item command="delete" divided>删除</el-dropdown-item>
            </el-dropdown-menu>
          </el-dropdown>
          </div>
          <div class="action-right">
            <el-dropdown trigger="click" @command="function(cmd){ handleRouteCommand(cmd, route, idx); }">
              <el-button size="mini" plain>
                更多<i class="el-icon-arrow-down el-icon--right"></i>
              </el-button>
              <el-dropdown-menu slot="dropdown">
                <el-dropdown-item command="cooldown" :disabled="!route.id">清冷却</el-dropdown-item>
                <el-dropdown-item command="delete" divided>删除</el-dropdown-item>
              </el-dropdown-menu>
            </el-dropdown>
          </div>
        </div>
      </div>
    </div>
  </div>
  <el-dialog title="LLM调用日志" :visible.sync="logDialogVisible" width="88%" :close-on-click-modal="false">
    <div class="log-toolbar">
      <el-select v-model="logQuery.scene" size="mini" clearable placeholder="场景" style="width:180px;">
        <el-option label="chat" value="chat"></el-option>
        <el-option label="chat_completion" value="chat_completion"></el-option>
        <el-option label="chat_completion_tools" value="chat_completion_tools"></el-option>
        <el-option label="chat_stream" value="chat_stream"></el-option>
        <el-option label="chat_stream_tools" value="chat_stream_tools"></el-option>
      </el-select>
      <el-select v-model="logQuery.success" size="mini" clearable placeholder="结果" style="width:120px;">
        <el-option label="成功" :value="1"></el-option>
        <el-option label="失败" :value="0"></el-option>
      </el-select>
      <el-input v-model="logQuery.traceId" size="mini" placeholder="traceId" style="width:260px;"></el-input>
      <el-button type="primary" size="mini" @click="loadLogs(1)">查询</el-button>
      <el-button size="mini" @click="resetLogQuery">重置</el-button>
      <el-button type="danger" plain size="mini" @click="clearLogs">清空日志</el-button>
    </div>
    <el-table :data="logPage.records" border stripe height="56vh" v-loading="logLoading" :header-cell-style="{background:'#f7f9fc', color:'#2e3a4d', fontWeight:600}">
      <el-table-column label="时间" width="165">
        <template slot-scope="scope">
          {{ formatDateTime(scope.row.createTime) }}
        </template>
      </el-table-column>
      <el-table-column prop="scene" label="场景" width="165"></el-table-column>
      <el-table-column prop="attemptNo" label="尝试" width="70"></el-table-column>
      <el-table-column prop="routeName" label="路由" width="170"></el-table-column>
      <el-table-column prop="model" label="模型" width="150"></el-table-column>
      <el-table-column label="结果" width="85">
        <template slot-scope="scope">
          <el-tag size="mini" :type="scope.row.success === 1 ? 'success' : 'danger'">
            {{ scope.row.success === 1 ? '成功' : '失败' }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column prop="httpStatus" label="状态码" width="90"></el-table-column>
      <el-table-column prop="latencyMs" label="耗时(ms)" width="95"></el-table-column>
      <el-table-column prop="traceId" label="TraceId" width="230"></el-table-column>
      <el-table-column label="错误" min-width="220">
        <template slot-scope="scope">
          <div class="log-text">{{ scope.row.errorMessage || '-' }}</div>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="120" fixed="right">
        <template slot-scope="scope">
          <el-button type="text" size="mini" @click="showLogDetail(scope.row)">详情</el-button>
          <el-button type="text" size="mini" style="color:#F56C6C;" @click="deleteLog(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
    <div style="margin-top:10px;text-align:right;">
      <el-pagination
        background
        layout="total, prev, pager, next"
        :current-page="logPage.curr"
        :page-size="logPage.limit"
        :total="logPage.total"
        @current-change="loadLogs">
      </el-pagination>
    </div>
  </el-dialog>
  <el-dialog :title="logDetailTitle || '日志详情'" :visible.sync="logDetailVisible" width="82%" :close-on-click-modal="false" append-to-body>
    <div class="log-detail-body">{{ logDetailText || '-' }}</div>
    <span slot="footer" class="dialog-footer">
      <el-button size="mini" @click="copyText(logDetailText)">复制全文</el-button>
      <el-button type="primary" size="mini" @click="logDetailVisible = false">关闭</el-button>
    </span>
  </el-dialog>
</div>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
@@ -227,7 +466,23 @@
      return {
        headerIcon: getAiIconHtml(34, 34),
        loading: false,
        routes: []
        routes: [],
        logDialogVisible: false,
        logLoading: false,
        logDetailVisible: false,
        logDetailTitle: '',
        logDetailText: '',
        logQuery: {
          scene: '',
          success: '',
          traceId: ''
        },
        logPage: {
          records: [],
          curr: 1,
          limit: 20,
          total: 0
        }
      };
    },
    computed: {
@@ -246,6 +501,99 @@
      }
    },
    methods: {
      formatDateTime: function(input) {
        if (!input) return '-';
        var d = input instanceof Date ? input : new Date(input);
        if (isNaN(d.getTime())) return String(input);
        var pad = function(n) { return n < 10 ? ('0' + n) : String(n); };
        var y = d.getFullYear();
        var m = pad(d.getMonth() + 1);
        var day = pad(d.getDate());
        var h = pad(d.getHours());
        var mm = pad(d.getMinutes());
        var s = pad(d.getSeconds());
        return y + '-' + m + '-' + day + ' ' + h + ':' + mm + ':' + s;
      },
      isRouteCooling: function(route) {
        if (!route || !route.cooldownUntil) return false;
        var x = new Date(route.cooldownUntil).getTime();
        return !isNaN(x) && x > Date.now();
      },
      routeCardClass: function(route) {
        return {
          cooling: this.isRouteCooling(route),
          disabled: route && route.status !== 1
        };
      },
      copyApiKey: function(route) {
        var self = this;
        var text = route && route.apiKey ? String(route.apiKey) : '';
        if (!text) {
          self.$message.warning('API Key 为空');
          return;
        }
        var afterCopy = function(ok) {
          if (ok) self.$message.success('API Key 已复制');
          else self.$message.error('复制失败,请手动复制');
        };
        if (navigator && navigator.clipboard && window.isSecureContext) {
          navigator.clipboard.writeText(text)
            .then(function(){ afterCopy(true); })
            .catch(function(){ afterCopy(false); });
          return;
        }
        var ta = document.createElement('textarea');
        ta.value = text;
        ta.setAttribute('readonly', 'readonly');
        ta.style.position = 'fixed';
        ta.style.left = '-9999px';
        document.body.appendChild(ta);
        ta.focus();
        ta.select();
        var ok = false;
        try {
          ok = document.execCommand('copy');
        } catch (e) {
          ok = false;
        }
        document.body.removeChild(ta);
        afterCopy(ok);
      },
      copyText: function(text) {
        var self = this;
        var val = text ? String(text) : '';
        if (!val) {
          self.$message.warning('没有可复制内容');
          return;
        }
        var done = function(ok) {
          if (ok) self.$message.success('已复制');
          else self.$message.error('复制失败,请手动复制');
        };
        if (navigator && navigator.clipboard && window.isSecureContext) {
          navigator.clipboard.writeText(val).then(function(){ done(true); }).catch(function(){ done(false); });
          return;
        }
        var ta = document.createElement('textarea');
        ta.value = val;
        ta.setAttribute('readonly', 'readonly');
        ta.style.position = 'fixed';
        ta.style.left = '-9999px';
        document.body.appendChild(ta);
        ta.focus();
        ta.select();
        var ok = false;
        try {
          ok = document.execCommand('copy');
        } catch (e) {
          ok = false;
        }
        document.body.removeChild(ta);
        done(ok);
      },
      authHeaders: function() {
        return { 'token': localStorage.getItem('token') };
      },
@@ -255,6 +603,109 @@
        if (command === 'cooldown') return this.clearCooldown(route);
        if (command === 'delete') return this.deleteRoute(route, idx);
      },
      openLogDialog: function() {
        this.logDialogVisible = true;
        this.loadLogs(1);
      },
      resetLogQuery: function() {
        this.logQuery.scene = '';
        this.logQuery.success = '';
        this.logQuery.traceId = '';
        this.loadLogs(1);
      },
      buildLogQuery: function(curr) {
        var q = [];
        q.push('curr=' + encodeURIComponent(curr || 1));
        q.push('limit=' + encodeURIComponent(this.logPage.limit));
        if (this.logQuery.scene) q.push('scene=' + encodeURIComponent(this.logQuery.scene));
        if (this.logQuery.success !== '' && this.logQuery.success !== null && this.logQuery.success !== undefined) {
          q.push('success=' + encodeURIComponent(this.logQuery.success));
        }
        if (this.logQuery.traceId) q.push('traceId=' + encodeURIComponent(this.logQuery.traceId));
        return q.join('&');
      },
      loadLogs: function(curr) {
        var self = this;
        self.logLoading = true;
        fetch(baseUrl + '/ai/llm/log/list/auth?' + self.buildLogQuery(curr), { headers: self.authHeaders() })
          .then(function(r){ return r.json(); })
          .then(function(res){
            self.logLoading = false;
            if (!res || res.code !== 200) {
              self.$message.error((res && res.msg) ? res.msg : '日志加载失败');
              return;
            }
            var p = res.data || {};
            self.logPage.records = Array.isArray(p.records) ? p.records : [];
            self.logPage.curr = p.current || curr || 1;
            self.logPage.limit = p.size || self.logPage.limit;
            self.logPage.total = p.total || 0;
          })
          .catch(function(){
            self.logLoading = false;
            self.$message.error('日志加载失败');
          });
      },
      showLogDetail: function(row) {
        var text = ''
          + '时间: ' + this.formatDateTime(row.createTime) + '\n'
          + 'TraceId: ' + (row.traceId || '-') + '\n'
          + '场景: ' + (row.scene || '-') + '\n'
          + '路由: ' + (row.routeName || '-') + '\n'
          + '模型: ' + (row.model || '-') + '\n'
          + '状态码: ' + (row.httpStatus != null ? row.httpStatus : '-') + '\n'
          + '耗时: ' + (row.latencyMs != null ? row.latencyMs : '-') + ' ms\n'
          + '结果: ' + (row.success === 1 ? '成功' : '失败') + '\n'
          + '错误: ' + (row.errorMessage || '-') + '\n\n'
          + '请求:\n' + (row.requestContent || '-') + '\n\n'
          + '响应:\n' + (row.responseContent || '-');
        this.logDetailTitle = '日志详情 - ' + (row.traceId || row.id || '');
        this.logDetailText = text;
        this.logDetailVisible = true;
      },
      deleteLog: function(row) {
        var self = this;
        if (!row || !row.id) return;
        self.$confirm('确定删除该日志吗?', '提示', { type: 'warning' }).then(function() {
          fetch(baseUrl + '/ai/llm/log/delete/auth?id=' + encodeURIComponent(row.id), {
            method: 'POST',
            headers: self.authHeaders()
          })
            .then(function(r){ return r.json(); })
            .then(function(res){
              if (res && res.code === 200) {
                self.$message.success('删除成功');
                self.loadLogs(self.logPage.curr);
              } else {
                self.$message.error((res && res.msg) ? res.msg : '删除失败');
              }
            })
            .catch(function(){
              self.$message.error('删除失败');
            });
        }).catch(function(){});
      },
      clearLogs: function() {
        var self = this;
        self.$confirm('确定清空全部LLM调用日志吗?', '提示', { type: 'warning' }).then(function() {
          fetch(baseUrl + '/ai/llm/log/clear/auth', {
            method: 'POST',
            headers: self.authHeaders()
          })
            .then(function(r){ return r.json(); })
            .then(function(res){
              if (res && res.code === 200) {
                self.$message.success('已清空');
                self.loadLogs(1);
              } else {
                self.$message.error((res && res.msg) ? res.msg : '清空失败');
              }
            })
            .catch(function(){
              self.$message.error('清空失败');
            });
        }).catch(function(){});
      },
      loadRoutes: function() {
        var self = this;
        self.loading = true;