From 825813e2dd90cf8bdc48acbb6eee85159bc33b4d Mon Sep 17 00:00:00 2001
From: Junjie <fallin.jie@qq.com>
Date: 星期二, 03 三月 2026 13:04:28 +0800
Subject: [PATCH] #AI LLM路由
---
src/main/java/com/zy/ai/service/LlmCallLogService.java | 8
src/main/java/com/zy/ai/controller/LlmCallLogController.java | 65 +++
src/main/java/com/zy/ai/mapper/LlmCallLogMapper.java | 11
src/main/java/com/zy/ai/service/LlmChatService.java | 185 ++++++++
src/main/java/com/zy/ai/service/impl/LlmCallLogServiceImpl.java | 33 +
src/main/java/com/zy/ai/service/LlmRoutingService.java | 18
src/main/resources/mapper/LlmCallLogMapper.xml | 27 +
src/main/webapp/views/ai/llm_config.html | 623 +++++++++++++++++++++++++----
src/main/java/com/zy/ai/entity/LlmCallLog.java | 219 ++++++++++
src/main/resources/sql/20260303_create_sys_llm_call_log.sql | 26 +
10 files changed, 1,114 insertions(+), 101 deletions(-)
diff --git a/src/main/java/com/zy/ai/controller/LlmCallLogController.java b/src/main/java/com/zy/ai/controller/LlmCallLogController.java
new file mode 100644
index 0000000..20670cb
--- /dev/null
+++ b/src/main/java/com/zy/ai/controller/LlmCallLogController.java
@@ -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();
+ }
+}
diff --git a/src/main/java/com/zy/ai/entity/LlmCallLog.java b/src/main/java/com/zy/ai/entity/LlmCallLog.java
new file mode 100644
index 0000000..bf11dc1
--- /dev/null
+++ b/src/main/java/com/zy/ai/entity/LlmCallLog.java
@@ -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;
+ }
+}
diff --git a/src/main/java/com/zy/ai/mapper/LlmCallLogMapper.java b/src/main/java/com/zy/ai/mapper/LlmCallLogMapper.java
new file mode 100644
index 0000000..8ebdc95
--- /dev/null
+++ b/src/main/java/com/zy/ai/mapper/LlmCallLogMapper.java
@@ -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> {
+}
diff --git a/src/main/java/com/zy/ai/service/LlmCallLogService.java b/src/main/java/com/zy/ai/service/LlmCallLogService.java
new file mode 100644
index 0000000..8d83871
--- /dev/null
+++ b/src/main/java/com/zy/ai/service/LlmCallLogService.java
@@ -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);
+}
diff --git a/src/main/java/com/zy/ai/service/LlmChatService.java b/src/main/java/com/zy/ai/service/LlmChatService.java
index 4e6bf19..3e25561 100644
--- a/src/main/java/com/zy/ai/service/LlmChatService.java
+++ b/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 璺敱娴佸紓甯稿畬鎴愶紝鑷姩鍒囨崲锛宑urrent={}", 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;
diff --git a/src/main/java/com/zy/ai/service/LlmRoutingService.java b/src/main/java/com/zy/ai/service/LlmRoutingService.java
index 4323d6a..96c4805 100644
--- a/src/main/java/com/zy/ai/service/LlmRoutingService.java
+++ b/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 璺敱鍧囧浜庡喎鍗达紝闄嶇骇鍚敤鍐峰嵈璺敱銆俢ooling={}, total={}", coolingRoutes.size(), total);
return coolingRoutes;
}
+ result.sort(ROUTE_ORDER);
if (result.isEmpty()) {
log.warn("鏈壘鍒板彲鐢� LLM 璺敱銆倀otal={}, 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;
}
diff --git a/src/main/java/com/zy/ai/service/impl/LlmCallLogServiceImpl.java b/src/main/java/com/zy/ai/service/impl/LlmCallLogServiceImpl.java
new file mode 100644
index 0000000..0a4e3ac
--- /dev/null
+++ b/src/main/java/com/zy/ai/service/impl/LlmCallLogServiceImpl.java
@@ -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璋冪敤鏃ュ織琛ㄤ笉瀛樺湪锛屾棩蹇楄褰曞凡鑷姩鍏抽棴锛岃鍏堟墽琛屽缓琛⊿QL");
+ return;
+ }
+ log.warn("鍐欏叆LLM璋冪敤鏃ュ織澶辫触: {}", msg);
+ }
+ }
+}
diff --git a/src/main/resources/mapper/LlmCallLogMapper.xml b/src/main/resources/mapper/LlmCallLogMapper.xml
new file mode 100644
index 0000000..9ed9d41
--- /dev/null
+++ b/src/main/resources/mapper/LlmCallLogMapper.xml
@@ -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>
diff --git a/src/main/resources/sql/20260303_create_sys_llm_call_log.sql b/src/main/resources/sql/20260303_create_sys_llm_call_log.sql
new file mode 100644
index 0000000..7291358
--- /dev/null
+++ b/src/main/resources/sql/20260303_create_sys_llm_call_log.sql
@@ -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 '涓�娆¤皟鐢ㄩ摼璺疘D',
+ `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璋冪敤鏃ュ織';
diff --git a/src/main/webapp/views/ai/llm_config.html b/src/main/webapp/views/ai/llm_config.html
index dbbbc6a..e5c052e 100644
--- a/src/main/webapp/views/ai/llm_config.html
+++ b/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;
--
Gitblit v1.9.1