commit 3438971d10d34a1bcb2ccb0e8d27d9629db901a7 Author: tanglong <842690096@qq.com> Date: Mon Jun 1 19:09:40 2026 +0800 首次提交 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16b61c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target/ +/.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..a0452b2 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Agent Foundation + +Java Spring Boot base platform for multiple enterprise agents (CRM assistant, knowledge-base assistant, customer service assistant). + +## Core API +- POST `/v1/chat/completions` +- GET `/v1/healthz` + +## WeCom CRM Bot Integration +- Callback URL: `/v1/integrations/wecom/callback` +- Receive enterprise WeCom text messages and route to `crm-assistant` +- Send the model response back to the same WeCom user + +### Required env vars +- `DEEPSEEK_API_KEY` +- `WECOM_CORP_ID` +- `WECOM_AGENT_ID` +- `WECOM_SECRET` +- `WECOM_TOKEN` + +### WeCom backend settings +1. Create a WeCom self-built app. +2. Enable message receive API and set callback URL to: + - `https://your-domain/v1/integrations/wecom/callback` +3. Set callback Token equal to `WECOM_TOKEN`. +4. Make sure app has send-message permission to internal users. + +## Run +1. Set env vars. +2. Start app from IDE or `mvn spring-boot:run`. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a200b36 --- /dev/null +++ b/pom.xml @@ -0,0 +1,23 @@ + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.0 + + + com.company + agent-foundation + 0.0.1-SNAPSHOT + 17 + + org.springframework.bootspring-boot-starter-web + org.springframework.bootspring-boot-starter-webflux + org.springframework.bootspring-boot-starter-validation + org.springframework.bootspring-boot-starter-actuator + com.fasterxml.jackson.dataformatjackson-dataformat-xml + org.springframework.bootspring-boot-starter-testtest + + org.springframework.bootspring-boot-maven-plugin + diff --git a/src/main/java/com/company/agentfoundation/AgentFoundationApplication.java b/src/main/java/com/company/agentfoundation/AgentFoundationApplication.java new file mode 100644 index 0000000..e8e854d --- /dev/null +++ b/src/main/java/com/company/agentfoundation/AgentFoundationApplication.java @@ -0,0 +1,7 @@ +package com.company.agentfoundation; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +@SpringBootApplication +public class AgentFoundationApplication { + public static void main(String[] args) { SpringApplication.run(AgentFoundationApplication.class, args); } +} diff --git a/src/main/java/com/company/agentfoundation/api/DeepseekChatRequest.java b/src/main/java/com/company/agentfoundation/api/DeepseekChatRequest.java new file mode 100644 index 0000000..5743784 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/api/DeepseekChatRequest.java @@ -0,0 +1,5 @@ +package com.company.agentfoundation.api; +import java.util.List; +public record DeepseekChatRequest(String model, List messages, Double temperature, Boolean stream) { + public record Message(String role, String content) {} +} diff --git a/src/main/java/com/company/agentfoundation/api/DeepseekChatResponse.java b/src/main/java/com/company/agentfoundation/api/DeepseekChatResponse.java new file mode 100644 index 0000000..6d1f7d6 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/api/DeepseekChatResponse.java @@ -0,0 +1,7 @@ +package com.company.agentfoundation.api; +import java.util.List; +public record DeepseekChatResponse(List choices, Usage usage) { + public record Choice(Message message) {} + public record Message(String role, String content) {} + public record Usage(Integer prompt_tokens, Integer completion_tokens, Integer total_tokens) {} +} diff --git a/src/main/java/com/company/agentfoundation/api/DeepseekClient.java b/src/main/java/com/company/agentfoundation/api/DeepseekClient.java new file mode 100644 index 0000000..88a8079 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/api/DeepseekClient.java @@ -0,0 +1,28 @@ +package com.company.agentfoundation.api; +import com.company.agentfoundation.config.FoundationProperties; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.util.retry.Retry; +import java.time.Duration; +@Component +public class DeepseekClient { + private final FoundationProperties properties; + private final WebClient webClient; + public DeepseekClient(FoundationProperties properties, WebClient.Builder builder) { + this.properties = properties; + this.webClient = builder.baseUrl(properties.getDeepseek().getBaseUrl()).defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).build(); + } + public DeepseekChatResponse chat(DeepseekChatRequest request) { + if (properties.getDeepseek().getApiKey() == null || properties.getDeepseek().getApiKey().isBlank()) throw new IllegalStateException("DEEPSEEK_API_KEY is missing"); + return webClient.post() + .uri(properties.getDeepseek().getChatPath()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + properties.getDeepseek().getApiKey()) + .bodyValue(request) + .retrieve() + .bodyToMono(DeepseekChatResponse.class) + .retryWhen(Retry.backoff(properties.getDeepseek().getMaxRetries(), Duration.ofMillis(300))) + .block(Duration.ofMillis(properties.getDeepseek().getReadTimeoutMs())); + } +} diff --git a/src/main/java/com/company/agentfoundation/config/FoundationConfig.java b/src/main/java/com/company/agentfoundation/config/FoundationConfig.java new file mode 100644 index 0000000..45861b7 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/config/FoundationConfig.java @@ -0,0 +1,6 @@ +package com.company.agentfoundation.config; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +@Configuration +@EnableConfigurationProperties(FoundationProperties.class) +public class FoundationConfig {} diff --git a/src/main/java/com/company/agentfoundation/config/FoundationProperties.java b/src/main/java/com/company/agentfoundation/config/FoundationProperties.java new file mode 100644 index 0000000..015a50e --- /dev/null +++ b/src/main/java/com/company/agentfoundation/config/FoundationProperties.java @@ -0,0 +1,78 @@ +package com.company.agentfoundation.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.HashMap; +import java.util.Map; + +@ConfigurationProperties(prefix = "foundation") +public class FoundationProperties { + private final Auth auth = new Auth(); + private final ModelRouter modelRouter = new ModelRouter(); + private final Deepseek deepseek = new Deepseek(); + private final Wecom wecom = new Wecom(); + + public Auth getAuth() { return auth; } + public ModelRouter getModelRouter() { return modelRouter; } + public Deepseek getDeepseek() { return deepseek; } + public Wecom getWecom() { return wecom; } + + public static class Auth { + private Map keys = new HashMap<>(); + public Map getKeys() { return keys; } + public void setKeys(Map keys) { this.keys = keys; } + } + + public static class ModelRouter { + private String defaultModel = "deepseek-chat"; + private Map routeByAgent = new HashMap<>(); + public String getDefaultModel() { return defaultModel; } + public void setDefaultModel(String defaultModel) { this.defaultModel = defaultModel; } + public Map getRouteByAgent() { return routeByAgent; } + public void setRouteByAgent(Map routeByAgent) { this.routeByAgent = routeByAgent; } + } + + public static class Deepseek { + private String baseUrl; + private String chatPath; + private String apiKey; + private int readTimeoutMs = 60000; + private int maxRetries = 2; + + public String getBaseUrl() { return baseUrl; } + public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } + public String getChatPath() { return chatPath; } + public void setChatPath(String chatPath) { this.chatPath = chatPath; } + public String getApiKey() { return apiKey; } + public void setApiKey(String apiKey) { this.apiKey = apiKey; } + public int getReadTimeoutMs() { return readTimeoutMs; } + public void setReadTimeoutMs(int readTimeoutMs) { this.readTimeoutMs = readTimeoutMs; } + public int getMaxRetries() { return maxRetries; } + public void setMaxRetries(int maxRetries) { this.maxRetries = maxRetries; } + } + + public static class Wecom { + private boolean enabled; + private String corpId; + private Integer agentId; + private String secret; + private String token; + private String crmAgentId = "crm-assistant"; + private String callbackPath = "/v1/integrations/wecom/callback"; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getCorpId() { return corpId; } + public void setCorpId(String corpId) { this.corpId = corpId; } + public Integer getAgentId() { return agentId; } + public void setAgentId(Integer agentId) { this.agentId = agentId; } + public String getSecret() { return secret; } + public void setSecret(String secret) { this.secret = secret; } + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + public String getCrmAgentId() { return crmAgentId; } + public void setCrmAgentId(String crmAgentId) { this.crmAgentId = crmAgentId; } + public String getCallbackPath() { return callbackPath; } + public void setCallbackPath(String callbackPath) { this.callbackPath = callbackPath; } + } +} diff --git a/src/main/java/com/company/agentfoundation/controller/ChatController.java b/src/main/java/com/company/agentfoundation/controller/ChatController.java new file mode 100644 index 0000000..d6d44f1 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/controller/ChatController.java @@ -0,0 +1,23 @@ +package com.company.agentfoundation.controller; +import com.company.agentfoundation.domain.ChatRequest; +import com.company.agentfoundation.domain.ChatResponse; +import com.company.agentfoundation.security.AgentAuthService; +import com.company.agentfoundation.security.AgentContext; +import com.company.agentfoundation.service.ChatService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/v1") +public class ChatController { + private final AgentAuthService authService; + private final ChatService chatService; + public ChatController(AgentAuthService authService, ChatService chatService) { this.authService = authService; this.chatService = chatService; } + @PostMapping("/chat/completions") + public ResponseEntity chat(@RequestHeader("X-Agent-Id") String agentId, @RequestHeader("X-Internal-Key") String internalKey, @Valid @RequestBody ChatRequest request) { + AgentContext context = authService.authenticate(agentId, internalKey); + return ResponseEntity.ok(chatService.chat(context, request)); + } + @GetMapping("/healthz") + public ResponseEntity healthz() { return ResponseEntity.ok("ok"); } +} diff --git a/src/main/java/com/company/agentfoundation/controller/GlobalExceptionHandler.java b/src/main/java/com/company/agentfoundation/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..148b1be --- /dev/null +++ b/src/main/java/com/company/agentfoundation/controller/GlobalExceptionHandler.java @@ -0,0 +1,38 @@ +package com.company.agentfoundation.controller; + +import com.company.agentfoundation.security.UnauthorizedException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity> unauthorized(UnauthorizedException ex) { + return build(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> badRequest(IllegalArgumentException ex) { + return build(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> generic(Exception ex) { + return build(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + } + + private ResponseEntity> build(HttpStatus status, String message) { + Map body = new HashMap<>(); + body.put("timestamp", Instant.now()); + body.put("status", status.value()); + body.put("error", status.getReasonPhrase()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/company/agentfoundation/domain/ChatRequest.java b/src/main/java/com/company/agentfoundation/domain/ChatRequest.java new file mode 100644 index 0000000..a1be53f --- /dev/null +++ b/src/main/java/com/company/agentfoundation/domain/ChatRequest.java @@ -0,0 +1,7 @@ +package com.company.agentfoundation.domain; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +public record ChatRequest(String model, @NotEmpty List messages, Double temperature) { + public record Message(@NotBlank String role, @NotBlank String content) {} +} diff --git a/src/main/java/com/company/agentfoundation/domain/ChatResponse.java b/src/main/java/com/company/agentfoundation/domain/ChatResponse.java new file mode 100644 index 0000000..3b7db18 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/domain/ChatResponse.java @@ -0,0 +1,5 @@ +package com.company.agentfoundation.domain; +import java.time.Instant; +public record ChatResponse(String traceId, String agentId, String model, String content, Usage usage, Instant timestamp) { + public record Usage(Integer promptTokens, Integer completionTokens, Integer totalTokens) {} +} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/WecomApiResponse.java b/src/main/java/com/company/agentfoundation/integration/wecom/WecomApiResponse.java new file mode 100644 index 0000000..90b89ee --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/WecomApiResponse.java @@ -0,0 +1,7 @@ +package com.company.agentfoundation.integration.wecom; + +public record WecomApiResponse(Integer errcode, String errmsg) { + public boolean success() { + return errcode != null && errcode == 0; + } +} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/WecomCallbackController.java b/src/main/java/com/company/agentfoundation/integration/wecom/WecomCallbackController.java new file mode 100644 index 0000000..3c52d86 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/WecomCallbackController.java @@ -0,0 +1,97 @@ +package com.company.agentfoundation.integration.wecom; + +import com.company.agentfoundation.config.FoundationProperties; +import com.company.agentfoundation.domain.ChatRequest; +import com.company.agentfoundation.domain.ChatResponse; +import com.company.agentfoundation.security.AgentContext; +import com.company.agentfoundation.service.ChatService; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; + +@RestController +@RequestMapping("/v1/integrations/wecom") +public class WecomCallbackController { + private final FoundationProperties properties; + private final ChatService chatService; + private final WecomClient wecomClient; + private final XmlMapper xmlMapper = new XmlMapper(); + + public WecomCallbackController(FoundationProperties properties, ChatService chatService, WecomClient wecomClient) { + this.properties = properties; + this.chatService = chatService; + this.wecomClient = wecomClient; + } + + @GetMapping("/callback") + public String verify(@RequestParam("msg_signature") String signature, + @RequestParam("timestamp") String timestamp, + @RequestParam("nonce") String nonce, + @RequestParam("echostr") String echostr) { + if (!validSignature(signature, timestamp, nonce)) { + throw new IllegalArgumentException("Invalid WeCom signature"); + } + return echostr; + } + + @PostMapping(value = "/callback", consumes = {MediaType.TEXT_XML_VALUE, MediaType.APPLICATION_XML_VALUE}) + public String receive(@RequestParam("msg_signature") String signature, + @RequestParam("timestamp") String timestamp, + @RequestParam("nonce") String nonce, + @RequestBody String body) throws Exception { + if (!validSignature(signature, timestamp, nonce)) { + throw new IllegalArgumentException("Invalid WeCom signature"); + } + + WecomIncomingMessage msg = xmlMapper.readValue(body, WecomIncomingMessage.class); + if (msg == null || msg.getMsgType() == null || !"text".equalsIgnoreCase(msg.getMsgType())) { + return "success"; + } + + String userText = msg.getContent() == null ? "" : msg.getContent().trim(); + if (userText.isBlank()) { + return "success"; + } + + ChatRequest req = new ChatRequest( + null, + List.of( + new ChatRequest.Message("system", "你是企业CRM智能助手。请给出简洁、可执行的客户跟进建议。"), + new ChatRequest.Message("user", userText) + ), + 0.3 + ); + + ChatResponse chat = chatService.chat(new AgentContext(properties.getWecom().getCrmAgentId()), req); + String reply = (chat.content() == null || chat.content().isBlank()) ? "我已收到你的请求,但暂时无法生成建议。" : chat.content(); + wecomClient.sendText(msg.getFromUserName(), reply); + + return "success"; + } + + private boolean validSignature(String signature, String timestamp, String nonce) { + String token = properties.getWecom().getToken(); + if (token == null || token.isBlank()) { + return false; + } + + String[] arr = new String[]{token, timestamp, nonce}; + Arrays.sort(arr); + String raw = String.join("", arr); + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + byte[] sha1 = digest.digest(raw.getBytes(StandardCharsets.UTF_8)); + String localSignature = HexFormat.of().formatHex(sha1); + return signature.equalsIgnoreCase(localSignature); + } catch (Exception e) { + return false; + } + } +} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/WecomClient.java b/src/main/java/com/company/agentfoundation/integration/wecom/WecomClient.java new file mode 100644 index 0000000..d37ff2f --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/WecomClient.java @@ -0,0 +1,68 @@ +package com.company.agentfoundation.integration.wecom; + +import com.company.agentfoundation.config.FoundationProperties; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Instant; + +@Service +public class WecomClient { + private final FoundationProperties properties; + private final WebClient webClient; + + private volatile String cachedToken; + private volatile Instant tokenExpireAt = Instant.EPOCH; + + public WecomClient(FoundationProperties properties, WebClient.Builder builder) { + this.properties = properties; + this.webClient = builder.baseUrl("https://qyapi.weixin.qq.com").build(); + } + + public void sendText(String toUser, String content) { + String token = getAccessToken(); + WecomSendMessageRequest req = new WecomSendMessageRequest(); + req.setToUser(toUser); + req.setAgentId(properties.getWecom().getAgentId()); + WecomSendMessageRequest.Text text = new WecomSendMessageRequest.Text(); + text.setContent(content); + req.setText(text); + + WecomApiResponse response = webClient.post() + .uri(uriBuilder -> uriBuilder.path("/cgi-bin/message/send").queryParam("access_token", token).build()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(req) + .retrieve() + .bodyToMono(WecomApiResponse.class) + .block(); + + if (response == null || !response.success()) { + throw new IllegalStateException("WeCom send failed: " + (response == null ? "null" : response.errmsg())); + } + } + + private String getAccessToken() { + if (cachedToken != null && Instant.now().isBefore(tokenExpireAt)) { + return cachedToken; + } + + WecomTokenResponse response = webClient.get() + .uri(uriBuilder -> uriBuilder.path("/cgi-bin/gettoken") + .queryParam("corpid", properties.getWecom().getCorpId()) + .queryParam("corpsecret", properties.getWecom().getSecret()) + .build()) + .retrieve() + .bodyToMono(WecomTokenResponse.class) + .block(); + + if (response == null || !response.success() || response.accessToken() == null) { + throw new IllegalStateException("WeCom gettoken failed: " + (response == null ? "null" : response.errmsg())); + } + + cachedToken = response.accessToken(); + int expires = response.expiresIn() == null ? 7200 : response.expiresIn(); + tokenExpireAt = Instant.now().plusSeconds(Math.max(300, expires - 120)); + return cachedToken; + } +} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/WecomIncomingMessage.java b/src/main/java/com/company/agentfoundation/integration/wecom/WecomIncomingMessage.java new file mode 100644 index 0000000..c02513b --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/WecomIncomingMessage.java @@ -0,0 +1,25 @@ +package com.company.agentfoundation.integration.wecom; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; + +@JacksonXmlRootElement(localName = "xml") +public class WecomIncomingMessage { + @JacksonXmlProperty(localName = "ToUserName") + private String toUserName; + @JacksonXmlProperty(localName = "FromUserName") + private String fromUserName; + @JacksonXmlProperty(localName = "MsgType") + private String msgType; + @JacksonXmlProperty(localName = "Content") + private String content; + + public String getToUserName() { return toUserName; } + public void setToUserName(String toUserName) { this.toUserName = toUserName; } + public String getFromUserName() { return fromUserName; } + public void setFromUserName(String fromUserName) { this.fromUserName = fromUserName; } + public String getMsgType() { return msgType; } + public void setMsgType(String msgType) { this.msgType = msgType; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } +} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/WecomSendMessageRequest.java b/src/main/java/com/company/agentfoundation/integration/wecom/WecomSendMessageRequest.java new file mode 100644 index 0000000..c07ba80 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/WecomSendMessageRequest.java @@ -0,0 +1,39 @@ +package com.company.agentfoundation.integration.wecom; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class WecomSendMessageRequest { + @JsonProperty("touser") + private String toUser; + @JsonProperty("msgtype") + private String msgType = "text"; + @JsonProperty("agentid") + private Integer agentId; + private Text text; + @JsonProperty("safe") + private Integer safe = 0; + + public String getToUser() { return toUser; } + public void setToUser(String toUser) { this.toUser = toUser; } + public String getMsgType() { return msgType; } + public void setMsgType(String msgType) { this.msgType = msgType; } + public Integer getAgentId() { return agentId; } + public void setAgentId(Integer agentId) { this.agentId = agentId; } + public Text getText() { return text; } + public void setText(Text text) { this.text = text; } + public Integer getSafe() { return safe; } + public void setSafe(Integer safe) { this.safe = safe; } + + public static class Text { + private String content; + @JsonProperty("mentioned_list") + private List mentionedList = List.of(); + + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public List getMentionedList() { return mentionedList; } + public void setMentionedList(List mentionedList) { this.mentionedList = mentionedList; } + } +} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/WecomTokenResponse.java b/src/main/java/com/company/agentfoundation/integration/wecom/WecomTokenResponse.java new file mode 100644 index 0000000..d8d6299 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/WecomTokenResponse.java @@ -0,0 +1,14 @@ +package com.company.agentfoundation.integration.wecom; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record WecomTokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") Integer expiresIn, + Integer errcode, + String errmsg +) { + public boolean success() { + return errcode == null || errcode == 0; + } +} diff --git a/src/main/java/com/company/agentfoundation/security/AgentAuthService.java b/src/main/java/com/company/agentfoundation/security/AgentAuthService.java new file mode 100644 index 0000000..2e5d378 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/security/AgentAuthService.java @@ -0,0 +1,13 @@ +package com.company.agentfoundation.security; +import com.company.agentfoundation.config.FoundationProperties; +import org.springframework.stereotype.Component; +@Component +public class AgentAuthService { + private final FoundationProperties properties; + public AgentAuthService(FoundationProperties properties) { this.properties = properties; } + public AgentContext authenticate(String agentId, String internalKey) { + String expected = properties.getAuth().getKeys().get(agentId); + if (expected == null || !expected.equals(internalKey)) throw new UnauthorizedException("Invalid agent credentials"); + return new AgentContext(agentId); + } +} diff --git a/src/main/java/com/company/agentfoundation/security/AgentContext.java b/src/main/java/com/company/agentfoundation/security/AgentContext.java new file mode 100644 index 0000000..caebf07 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/security/AgentContext.java @@ -0,0 +1,2 @@ +package com.company.agentfoundation.security; +public record AgentContext(String agentId) {} diff --git a/src/main/java/com/company/agentfoundation/security/UnauthorizedException.java b/src/main/java/com/company/agentfoundation/security/UnauthorizedException.java new file mode 100644 index 0000000..5317925 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/security/UnauthorizedException.java @@ -0,0 +1,4 @@ +package com.company.agentfoundation.security; +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { super(message); } +} diff --git a/src/main/java/com/company/agentfoundation/service/ChatService.java b/src/main/java/com/company/agentfoundation/service/ChatService.java new file mode 100644 index 0000000..f498bf1 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/service/ChatService.java @@ -0,0 +1,30 @@ +package com.company.agentfoundation.service; +import com.company.agentfoundation.api.DeepseekChatRequest; +import com.company.agentfoundation.api.DeepseekChatResponse; +import com.company.agentfoundation.api.DeepseekClient; +import com.company.agentfoundation.domain.ChatRequest; +import com.company.agentfoundation.domain.ChatResponse; +import com.company.agentfoundation.security.AgentContext; +import org.springframework.stereotype.Service; +import java.time.Instant; +import java.util.UUID; +@Service +public class ChatService { + private final DeepseekClient deepseekClient; + private final ModelRouter modelRouter; + public ChatService(DeepseekClient deepseekClient, ModelRouter modelRouter) { this.deepseekClient = deepseekClient; this.modelRouter = modelRouter; } + public ChatResponse chat(AgentContext context, ChatRequest request) { + String traceId = UUID.randomUUID().toString(); + String model = modelRouter.routeModel(context.agentId(), request.model()); + DeepseekChatResponse resp = deepseekClient.chat(new DeepseekChatRequest( + model, + request.messages().stream().map(m -> new DeepseekChatRequest.Message(m.role(), m.content())).toList(), + request.temperature(), + false)); + String content = ""; + if (resp != null && resp.choices() != null && !resp.choices().isEmpty() && resp.choices().get(0).message() != null) content = resp.choices().get(0).message().content(); + ChatResponse.Usage usage = null; + if (resp != null && resp.usage() != null) usage = new ChatResponse.Usage(resp.usage().prompt_tokens(), resp.usage().completion_tokens(), resp.usage().total_tokens()); + return new ChatResponse(traceId, context.agentId(), model, content, usage, Instant.now()); + } +} diff --git a/src/main/java/com/company/agentfoundation/service/ModelRouter.java b/src/main/java/com/company/agentfoundation/service/ModelRouter.java new file mode 100644 index 0000000..8717c40 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/service/ModelRouter.java @@ -0,0 +1,12 @@ +package com.company.agentfoundation.service; +import com.company.agentfoundation.config.FoundationProperties; +import org.springframework.stereotype.Component; +@Component +public class ModelRouter { + private final FoundationProperties properties; + public ModelRouter(FoundationProperties properties) { this.properties = properties; } + public String routeModel(String agentId, String requestedModel) { + if (requestedModel != null && !requestedModel.isBlank()) return requestedModel; + return properties.getModelRouter().getRouteByAgent().getOrDefault(agentId, properties.getModelRouter().getDefaultModel()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..0022c3d --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,36 @@ +server: + port: 8080 +spring: + application: + name: agent-foundation +management: + endpoints: + web: + exposure: + include: health,info,metrics +foundation: + auth: + keys: + crm-assistant: crm-dev-key + kb-assistant: kb-dev-key + cs-assistant: cs-dev-key + model-router: + default-model: deepseek-chat + route-by-agent: + crm-assistant: deepseek-chat + kb-assistant: deepseek-chat + cs-assistant: deepseek-chat + deepseek: + base-url: https://api.deepseek.com + chat-path: /chat/completions + api-key: ${DEEPSEEK_API_KEY:} + read-timeout-ms: 60000 + max-retries: 2 + wecom: + enabled: true + corp-id: ${WECOM_CORP_ID:} + agent-id: ${WECOM_AGENT_ID:1000002} + secret: ${WECOM_SECRET:} + token: ${WECOM_TOKEN:} + crm-agent-id: crm-assistant + callback-path: /v1/integrations/wecom/callback