| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | |
| | | import com.alibaba.fastjson.JSONObject; |
| | | import com.zy.ai.entity.ChatCompletionRequest; |
| | | import com.zy.ai.entity.ChatCompletionResponse; |
| | | import com.zy.ai.entity.LlmCallLog; |
| | | import com.zy.ai.entity.LlmRouteConfig; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | |
| | | import 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; |
| | |
| | | @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; |
| | |
| | | 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 || |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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; |
| | |
| | | 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, |
| | |
| | | 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, |
| | |
| | | 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; |
| | |
| | | |
| | | 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); |
| | |
| | | String content = delta.getString("content"); |
| | | if (content != null) { |
| | | queue.offer(content); |
| | | appendLimited(outputBuffer, content); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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); |
| | |
| | | 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); |
| | | } |
| | | }); |
| | |
| | | .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") |
| | |
| | | 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) { |
| | |
| | | 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; |
| | |
| | | |
| | | import java.time.Duration; |
| | | import java.util.ArrayList; |
| | | import java.util.Comparator; |
| | | import java.util.Collections; |
| | | import java.util.Date; |
| | | import java.util.HashMap; |
| | |
| | | |
| | | 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; |
| | |
| | | } |
| | | 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); |
| | | } |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <?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> |
| New file |
| | |
| | | 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调用日志'; |
| | |
| | | <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%), |
| | |
| | | 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; |
| | |
| | | } |
| | | @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> |
| | |
| | | <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"> |
| | |
| | | </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> |
| | |
| | | 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: { |
| | |
| | | } |
| | | }, |
| | | 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') }; |
| | | }, |
| | |
| | | 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; |