diff --git a/gradle.properties b/gradle.properties index e69de29..80e5580 100644 --- a/gradle.properties +++ b/gradle.properties @@ -0,0 +1,2 @@ +systemProp.org.gradle.internal.http.connectionTimeout=60000 +systemProp.org.gradle.internal.http.socketTimeout=120000 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b82aa23..e2b3e26 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip -networkTimeout=10000 +networkTimeout=60000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/com/ddaodan/MineChatGPT/CommandHandler.java b/src/main/java/com/ddaodan/MineChatGPT/CommandHandler.java index ff6bd07..079535c 100644 --- a/src/main/java/com/ddaodan/MineChatGPT/CommandHandler.java +++ b/src/main/java/com/ddaodan/MineChatGPT/CommandHandler.java @@ -2,22 +2,20 @@ package com.ddaodan.MineChatGPT; import com.ddaodan.MineChatGPT.service.ApiService; import com.ddaodan.MineChatGPT.service.CommandService; +import com.ddaodan.MineChatGPT.service.RequestCoordinator; import com.ddaodan.MineChatGPT.service.UserSessionManager; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; public class CommandHandler implements CommandExecutor { - private final Main plugin; private final ConfigManager configManager; private final CommandService commandService; private final UserSessionManager sessionManager; - public CommandHandler(Main plugin, ConfigManager configManager) { - this.plugin = plugin; + public CommandHandler(ConfigManager configManager, UserSessionManager sessionManager, RequestCoordinator requestCoordinator) { this.configManager = configManager; - this.sessionManager = new UserSessionManager(configManager); - ApiService apiService = new ApiService(configManager); - this.commandService = new CommandService(configManager, apiService, sessionManager); + this.sessionManager = sessionManager; + this.commandService = new CommandService(configManager, requestCoordinator, sessionManager); } @Override @@ -43,10 +41,12 @@ public class CommandHandler implements CommandExecutor { return commandService.handleClearCommand(sender, userId); } else if (subCommand.equalsIgnoreCase("character")) { return commandService.handleCharacterCommand(sender, args, userId); + } else if (subCommand.equalsIgnoreCase("stats")) { + return commandService.handleStatsCommand(sender, args, userId); } else { return commandService.handleAskCommand(sender, args, userId); } } return false; } -} \ No newline at end of file +} diff --git a/src/main/java/com/ddaodan/MineChatGPT/ConfigManager.java b/src/main/java/com/ddaodan/MineChatGPT/ConfigManager.java index aa3e8ab..c130243 100644 --- a/src/main/java/com/ddaodan/MineChatGPT/ConfigManager.java +++ b/src/main/java/com/ddaodan/MineChatGPT/ConfigManager.java @@ -1,17 +1,23 @@ package com.ddaodan.MineChatGPT; +import com.ddaodan.MineChatGPT.util.ConfigFileUpdater; import org.bukkit.ChatColor; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; public class ConfigManager { private final Main plugin; private FileConfiguration config; private String currentModel; private LanguageManager languageManager; + private volatile Map charactersCache; public ConfigManager(Main plugin) { this.plugin = plugin; @@ -19,14 +25,21 @@ public class ConfigManager { // 获取语言设置 String language = config.getString("language", "en"); this.languageManager = new LanguageManager(plugin, language); + this.charactersCache = null; } public boolean isDebugMode() { return config.getBoolean("debug", false); } public void reloadConfig() { + ConfigFileUpdater.UpdateResult updateResult = ConfigFileUpdater.updateIfMissingKeys(plugin, "config.yml"); + if (updateResult.updated) { + plugin.getLogger().info("Config updated: inserted " + updateResult.insertedPaths + + " missing path(s). Backup: " + updateResult.backupFileName); + } plugin.reloadConfig(); config = plugin.getConfig(); currentModel = config.getString("default_model"); + charactersCache = null; // 重新加载语言文件 if (languageManager != null) { @@ -44,34 +57,56 @@ public class ConfigManager { public void setCurrentModel(String model) { currentModel = model; } - private int currentKeyIndex = 0; + private final AtomicInteger currentKeyIndex = new AtomicInteger(0); public String getApiKey() { - List keys = config.getStringList("api.keys"); + List keys = new ArrayList<>(); + for (String key : config.getStringList("api.keys")) { + if (isUsableApiKey(key)) { + keys.add(key); + } + } if (keys.isEmpty()) { - // 向后兼容:如果没有找到keys列表,尝试使用旧的单一key配置 String legacyKey = config.getString("api.key"); - if (legacyKey != null && !legacyKey.isEmpty()) { + if (isUsableApiKey(legacyKey)) { return legacyKey; } return ""; } - + String selectionMethod = config.getString("api.selection_method", "round_robin"); - + if ("random".equalsIgnoreCase(selectionMethod)) { - // 随机选择一个key int randomIndex = (int) (Math.random() * keys.size()); return keys.get(randomIndex); } else { - // 默认使用轮询方式 - String key = keys.get(currentKeyIndex); - currentKeyIndex = (currentKeyIndex + 1) % keys.size(); - return key; + int index = Math.floorMod(currentKeyIndex.getAndIncrement(), keys.size()); + return keys.get(index); } } + + private boolean isUsableApiKey(String key) { + if (key == null) { + return false; + } + String trimmed = key.trim(); + if (trimmed.isEmpty()) { + return false; + } + return !trimmed.startsWith("sk-your_openai_api_key_"); + } + public String getBaseUrl() { - return config.getString("api.base_url"); + return config.getString("api.base_url", "https://api.openai.com/v1"); + } + public int getApiConnectTimeoutMs() { + return config.getInt("api.connect_timeout_ms", 10000); + } + public int getApiTimeoutMs() { + return config.getInt("api.timeout_ms", 30000); + } + public int getApiThreadPoolSize() { + return Math.max(1, config.getInt("api.thread_pool_size", 4)); } public String getDefaultModel() { return config.getString("default_model"); @@ -106,18 +141,51 @@ public class ConfigManager { public String getHelpCharacterMessage() { return languageManager.getMessage("help_character"); } + public String getHelpStatsMessage() { + return languageManager.getMessage("help_stats"); + } public String getModelSwitchMessage() { return languageManager.getMessage("model_switch"); } public String getChatGPTErrorMessage() { return languageManager.getMessage("chatgpt_error"); } + public String getNoApiKeyMessage() { + return languageManager.getMessage("no_api_key", "&cNo API key configured. Please set api.keys in config.yml."); + } public String getChatGPTResponseMessage() { return languageManager.getMessage("chatgpt_response", "&b%s: %s"); } public String getQuestionMessage() { return languageManager.getMessage("question"); } + public String getQueuedMessage() { + return languageManager.getMessage("queued", "&eYour request has been queued. Position: %s"); + } + public String getQueueFullMessage() { + return languageManager.getMessage("queue_full", "&cQueue is full. Please try again later."); + } + public String getQueueFullUserMessage() { + return languageManager.getMessage("queue_full_user", "&cYou have too many pending requests. Please wait."); + } + public String getCooldownMessage() { + return languageManager.getMessage("cooldown", "&cYou're sending requests too fast. Please wait %s ms."); + } + public String getRateLimitedMessage() { + return languageManager.getMessage("rate_limited", "&cRate limited. Please try again later."); + } + public String getStatsHeaderMessage() { + return languageManager.getMessage("stats_header", "&e===== MineChatGPT Stats ====="); + } + public String getStatsGlobalMessage() { + return languageManager.getMessage("stats_global", "&eGlobal tokens: %s (prompt=%s, completion=%s), requests=%s"); + } + public String getStatsUserMessage() { + return languageManager.getMessage("stats_user", "&eYour tokens: %s (prompt=%s, completion=%s), requests=%s"); + } + public String getStatsResetMessage() { + return languageManager.getMessage("stats_reset", "&aStats reset."); + } public String getInvalidModelMessage() { return languageManager.getMessage("invalid_model"); } @@ -133,6 +201,24 @@ public class ConfigManager { public int getMaxHistorySize() { return config.getInt("conversation.max_history_size", 10); } + public int getMaxContextTokens() { + return config.getInt("conversation.max_context_tokens", 2000); + } + public int getReserveCompletionTokens() { + return config.getInt("conversation.reserve_completion_tokens", 400); + } + public boolean isSummarizationEnabled() { + return config.getBoolean("conversation.summarization.enabled", true); + } + public String getSummarizationModel() { + return config.getString("conversation.summarization.model", getCurrentModel()); + } + public int getSummarizationTriggerTokens() { + return config.getInt("conversation.summarization.trigger_tokens", 1800); + } + public int getSummarizationKeepLastMessages() { + return Math.max(0, config.getInt("conversation.summarization.keep_last_messages", 6)); + } public boolean isContextEnabled() { return config.getBoolean("conversation.context_enabled", false); } @@ -158,11 +244,27 @@ public class ConfigManager { return languageManager.getMessage("invalid_character", "&cInvalid character. Use /chatgpt character to list available characters."); } public Map getCharacters() { - Map characters = new HashMap<>(); - config.getConfigurationSection("characters").getKeys(false).forEach(key -> { - characters.put(key, config.getString("characters." + key)); - }); - return characters; + Map cached = charactersCache; + if (cached != null) { + return cached; + } + + ConfigurationSection section = config.getConfigurationSection("characters"); + if (section == null) { + charactersCache = Collections.emptyMap(); + return charactersCache; + } + + Map characters = new LinkedHashMap<>(); + for (String key : section.getKeys(false)) { + String prompt = section.getString(key); + if (prompt != null) { + characters.put(key, prompt); + } + } + + charactersCache = Collections.unmodifiableMap(characters); + return charactersCache; } public String getCurrentCharacter(String userId) { return config.getString("users." + userId + ".character", "ChatGPT"); @@ -171,4 +273,62 @@ public class ConfigManager { config.set("users." + userId + ".character", character); plugin.saveConfig(); } -} \ No newline at end of file + + public boolean isRateLimitEnabled() { + return config.getBoolean("rate_limit.enabled", true); + } + public String getRateLimitMode() { + return config.getString("rate_limit.mode", "both"); + } + public long getRateLimitCooldownMs() { + return Math.max(0L, config.getLong("rate_limit.cooldown_ms", 1000L)); + } + public String getRateLimitTokenEstimator() { + return config.getString("rate_limit.token_estimator", "approx_chars"); + } + public int getRateLimitAssumedCompletionTokens() { + return Math.max(0, config.getInt("rate_limit.assumed_completion_tokens", 300)); + } + + public int getPerUserRequestsPerMinute() { + return Math.max(0, config.getInt("rate_limit.per_user.requests_per_minute", 6)); + } + public int getPerUserBurstRequests() { + return Math.max(0, config.getInt("rate_limit.per_user.burst_requests", 3)); + } + public int getPerUserTokensPerMinute() { + return Math.max(0, config.getInt("rate_limit.per_user.tokens_per_minute", 4000)); + } + public int getPerUserBurstTokens() { + return Math.max(0, config.getInt("rate_limit.per_user.burst_tokens", 2000)); + } + + public int getGlobalRequestsPerMinute() { + return Math.max(0, config.getInt("rate_limit.global.requests_per_minute", 60)); + } + public int getGlobalBurstRequests() { + return Math.max(0, config.getInt("rate_limit.global.burst_requests", 20)); + } + public int getGlobalTokensPerMinute() { + return Math.max(0, config.getInt("rate_limit.global.tokens_per_minute", 100000)); + } + public int getGlobalBurstTokens() { + return Math.max(0, config.getInt("rate_limit.global.burst_tokens", 20000)); + } + + public boolean isQueueEnabled() { + return config.getBoolean("queue.enabled", true); + } + public int getQueueMaxSize() { + return Math.max(0, config.getInt("queue.max_size", 100)); + } + public int getQueueMaxPerUser() { + return Math.max(0, config.getInt("queue.max_per_user", 3)); + } + public int getQueueMaxInFlight() { + return Math.max(1, config.getInt("queue.max_in_flight", getApiThreadPoolSize())); + } + public int getQueueDispatchPerTick() { + return Math.max(1, config.getInt("queue.dispatch_per_tick", 2)); + } +} diff --git a/src/main/java/com/ddaodan/MineChatGPT/ConversationContext.java b/src/main/java/com/ddaodan/MineChatGPT/ConversationContext.java index 1cdb283..c1972f9 100644 --- a/src/main/java/com/ddaodan/MineChatGPT/ConversationContext.java +++ b/src/main/java/com/ddaodan/MineChatGPT/ConversationContext.java @@ -1,29 +1,84 @@ package com.ddaodan.MineChatGPT; -import java.util.LinkedList; -import java.util.Queue; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; public class ConversationContext { - private Queue conversationHistory; - private int maxHistorySize; + public static final class Message { + private final String role; + private final String content; + + public Message(String role, String content) { + this.role = role; + this.content = content; + } + + public String getRole() { + return role; + } + + public String getContent() { + return content; + } + } + + private final Deque history; + private final int maxHistorySize; public ConversationContext(int maxHistorySize) { this.maxHistorySize = maxHistorySize; - this.conversationHistory = new LinkedList<>(); + this.history = new ArrayDeque<>(); } - public void addMessage(String message) { - if (conversationHistory.size() >= maxHistorySize) { - conversationHistory.poll(); + public void addUserMessage(String message) { + addMessage("user", message); + } + + public void addAssistantMessage(String message) { + addMessage("assistant", message); + } + + private void addMessage(String role, String message) { + if (history.size() >= maxHistorySize) { + history.pollFirst(); } - conversationHistory.offer(message); + history.addLast(new Message(role, message)); } - public String getConversationHistory() { - return String.join("\n", conversationHistory); + public List getMessages() { + return new ArrayList<>(history); + } + + public void setMessages(List messages) { + history.clear(); + if (messages == null || messages.isEmpty()) { + return; + } + int startIndex = Math.max(0, messages.size() - maxHistorySize); + for (int i = startIndex; i < messages.size(); i++) { + Message msg = messages.get(i); + if (msg != null) { + history.addLast(new Message(msg.getRole(), msg.getContent())); + } + } + } + + public void trimToLast(int keepLast) { + if (keepLast < 0) { + keepLast = 0; + } + while (history.size() > keepLast) { + history.pollFirst(); + } + } + + public void removeOldestMessage() { + history.pollFirst(); } public void clearHistory() { - conversationHistory.clear(); + history.clear(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/ddaodan/MineChatGPT/LanguageManager.java b/src/main/java/com/ddaodan/MineChatGPT/LanguageManager.java index 6bae836..4d1e6dd 100644 --- a/src/main/java/com/ddaodan/MineChatGPT/LanguageManager.java +++ b/src/main/java/com/ddaodan/MineChatGPT/LanguageManager.java @@ -1,5 +1,6 @@ package com.ddaodan.MineChatGPT; +import com.ddaodan.MineChatGPT.util.ConfigFileUpdater; import org.bukkit.ChatColor; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; @@ -23,22 +24,29 @@ public class LanguageManager { } public void loadLanguage() { + String resourcePath = "lang/" + currentLanguage + ".yml"; langFile = new File(plugin.getDataFolder(), "lang" + File.separator + currentLanguage + ".yml"); - - // 如果语言文件不存在,创建默认语言文件 - if (!langFile.exists()) { - langFile.getParentFile().mkdirs(); - plugin.saveResource("lang/" + currentLanguage + ".yml", false); + + if (plugin.getResource(resourcePath) != null) { + ConfigFileUpdater.UpdateResult updateResult = ConfigFileUpdater.updateIfMissingKeys(plugin, resourcePath); + if (updateResult.updated) { + plugin.getLogger().info("Language file updated (" + currentLanguage + "): inserted " + + updateResult.insertedPaths + " missing path(s). Backup: " + updateResult.backupFileName); + } + } else if (!langFile.exists()) { + plugin.getLogger().warning("Language resource not found: " + resourcePath + ", file does not exist."); } - + langConfig = YamlConfiguration.loadConfiguration(langFile); - - // 设置默认值,以防语言文件中缺少某些键 - InputStream defaultLangStream = plugin.getResource("lang/" + currentLanguage + ".yml"); + + InputStream defaultLangStream = plugin.getResource(resourcePath); if (defaultLangStream != null) { - YamlConfiguration defaultLang = YamlConfiguration.loadConfiguration( - new InputStreamReader(defaultLangStream, StandardCharsets.UTF_8)); - langConfig.setDefaults(defaultLang); + try (InputStreamReader reader = new InputStreamReader(defaultLangStream, StandardCharsets.UTF_8)) { + YamlConfiguration defaultLang = YamlConfiguration.loadConfiguration(reader); + langConfig.setDefaults(defaultLang); + } catch (IOException ex) { + plugin.getLogger().warning("Failed to load default language resource: " + ex.getMessage()); + } } } @@ -62,4 +70,4 @@ public class LanguageManager { public String getMessage(String path, String defaultValue) { return translateColorCodes(langConfig.getString("messages." + path, defaultValue)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/ddaodan/MineChatGPT/Main.java b/src/main/java/com/ddaodan/MineChatGPT/Main.java index 185bc86..1306138 100644 --- a/src/main/java/com/ddaodan/MineChatGPT/Main.java +++ b/src/main/java/com/ddaodan/MineChatGPT/Main.java @@ -7,18 +7,21 @@ import java.util.Objects; public final class Main extends JavaPlugin { private ConfigManager configManager; + private com.ddaodan.MineChatGPT.service.UserSessionManager sessionManager; + private com.ddaodan.MineChatGPT.service.ApiService apiService; + private com.ddaodan.MineChatGPT.service.RequestCoordinator requestCoordinator; private CommandHandler commandHandler; private MineChatGPTTabCompleter tabCompleter; - private LanguageManager languageManager; @Override public void onEnable() { saveDefaultConfig(); configManager = new ConfigManager(this); - // 初始化语言管理器 - String language = getConfig().getString("language", "en"); - languageManager = new LanguageManager(this, language); - commandHandler = new CommandHandler(this, configManager); + sessionManager = new com.ddaodan.MineChatGPT.service.UserSessionManager(configManager); + apiService = new com.ddaodan.MineChatGPT.service.ApiService(this, configManager); + requestCoordinator = new com.ddaodan.MineChatGPT.service.RequestCoordinator(this, configManager, apiService, sessionManager); + requestCoordinator.start(); + commandHandler = new CommandHandler(configManager, sessionManager, requestCoordinator); tabCompleter = new MineChatGPTTabCompleter(configManager); Objects.requireNonNull(getCommand("chatgpt")).setExecutor(commandHandler); Objects.requireNonNull(getCommand("chatgpt")).setTabCompleter(tabCompleter); @@ -32,6 +35,12 @@ public final class Main extends JavaPlugin { @Override public void onDisable() { + if (requestCoordinator != null) { + requestCoordinator.stop(); + } + if (apiService != null) { + apiService.shutdown(); + } saveConfig(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/ddaodan/MineChatGPT/MineChatGPTTabCompleter.java b/src/main/java/com/ddaodan/MineChatGPT/MineChatGPTTabCompleter.java index f3c875b..35b6549 100644 --- a/src/main/java/com/ddaodan/MineChatGPT/MineChatGPTTabCompleter.java +++ b/src/main/java/com/ddaodan/MineChatGPT/MineChatGPTTabCompleter.java @@ -26,12 +26,15 @@ public class MineChatGPTTabCompleter implements TabCompleter { completions.add("context"); completions.add("clear"); completions.add("character"); + completions.add("stats"); } else if (args.length == 2) { String subCommand = args[0]; if (subCommand.equalsIgnoreCase("model")) { completions.addAll(configManager.getModels()); } else if (subCommand.equalsIgnoreCase("character")) { completions.addAll(configManager.getCharacters().keySet()); + } else if (subCommand.equalsIgnoreCase("stats")) { + completions.add("reset"); } } diff --git a/src/main/java/com/ddaodan/MineChatGPT/service/ApiService.java b/src/main/java/com/ddaodan/MineChatGPT/service/ApiService.java index 34612f5..e65b3b4 100644 --- a/src/main/java/com/ddaodan/MineChatGPT/service/ApiService.java +++ b/src/main/java/com/ddaodan/MineChatGPT/service/ApiService.java @@ -1,155 +1,201 @@ package com.ddaodan.MineChatGPT.service; import com.ddaodan.MineChatGPT.ConfigManager; -import com.ddaodan.MineChatGPT.ConversationContext; -import org.bukkit.command.CommandSender; +import com.ddaodan.MineChatGPT.Main; +import jodd.http.HttpRequest; +import jodd.http.HttpResponse; import org.json.JSONArray; import org.json.JSONObject; -import java.nio.charset.StandardCharsets; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; import java.util.logging.Logger; -import jodd.http.HttpRequest; -import jodd.http.HttpResponse; - /** - * API服务类,负责处理与OpenAI API的通信 + * API服务类:负责与 OpenAI 兼容接口通信(/chat/completions) */ public class ApiService { - private final ConfigManager configManager; private static final Logger logger = Logger.getLogger(ApiService.class.getName()); - public ApiService(ConfigManager configManager) { + private final Main plugin; + private final ConfigManager configManager; + private final ExecutorService executor; + + public ApiService(Main plugin, ConfigManager configManager) { + this.plugin = plugin; this.configManager = configManager; + this.executor = Executors.newFixedThreadPool( + configManager.getApiThreadPoolSize(), + newNamedThreadFactory("minechatgpt-api-") + ); } - /** - * 向ChatGPT发送请求 - * - * @param sender 命令发送者 - * @param question 问题内容 - * @param conversationContext 对话上下文 - * @param contextEnabled 是否启用上下文 - * @param userId 用户ID - */ - public void askChatGPT(CommandSender sender, String question, ConversationContext conversationContext, boolean contextEnabled, String userId) { - String utf8Question = convertToUTF8(question); - JSONObject json = new JSONObject(); - json.put("model", configManager.getCurrentModel()); - JSONArray messages = new JSONArray(); - - // 添加自定义 prompt - String currentCharacter = configManager.getCurrentCharacter(userId); - String customPrompt = configManager.getCharacters().get(currentCharacter); - if (customPrompt != null && !customPrompt.isEmpty()) { - JSONObject promptMessage = new JSONObject(); - promptMessage.put("role", "system"); - promptMessage.put("content", customPrompt); - messages.put(promptMessage); + public void shutdown() { + executor.shutdownNow(); + } + + public CompletableFuture createChatCompletion(String model, JSONArray messages) { + if (model == null || model.trim().isEmpty()) { + return CompletableFuture.completedFuture(ChatCompletionResult.error("Missing model", null)); } - - JSONObject message = new JSONObject(); - message.put("role", "user"); - message.put("content", utf8Question); - messages.put(message); - - if (contextEnabled) { - String history = conversationContext.getConversationHistory(); - if (!history.isEmpty()) { - JSONObject historyMessage = new JSONObject(); - historyMessage.put("role", "system"); - historyMessage.put("content", history); - messages.put(historyMessage); - } - } - - json.put("messages", messages); - - if (configManager.isDebugMode()) { - logger.info("Built request: " + json.toString()); + if (messages == null) { + messages = new JSONArray(); } - HttpRequest request = HttpRequest.post(configManager.getBaseUrl() + "/chat/completions") + String apiKey = configManager.getApiKey(); + if (apiKey == null || apiKey.trim().isEmpty()) { + return CompletableFuture.completedFuture(ChatCompletionResult.error(configManager.getNoApiKeyMessage(), null)); + } + + JSONObject payload = new JSONObject(); + payload.put("model", model); + payload.put("messages", messages); + + if (configManager.isDebugMode()) { + logger.info("Built request: " + payload); + } + + String baseUrl = normalizeBaseUrl(configManager.getBaseUrl()); + HttpRequest request = HttpRequest + .post(baseUrl + "/chat/completions") .header("Content-Type", "application/json; charset=UTF-8") - .header("Authorization", "Bearer " + configManager.getApiKey()) - .bodyText(json.toString()); + .header("Accept", "application/json") + .header("Authorization", "Bearer " + apiKey) + .header("User-Agent", "MineChatGPT/" + plugin.getDescription().getVersion()) + .connectionTimeout(configManager.getApiConnectTimeoutMs()) + .timeout(configManager.getApiTimeoutMs()) + .bodyText(payload.toString()); if (configManager.isDebugMode()) { - logger.info("Sending request to ChatGPT: " + request.toString()); + logger.info("Sending request: " + request); } - CompletableFuture.supplyAsync(() -> request.send()) - .thenAccept(response -> { - if (configManager.isDebugMode()) { - logger.info("Received response from ChatGPT: " + response.toString()); - } - if (response.statusCode() == 200) { - processSuccessResponse(response, sender, conversationContext, contextEnabled, currentCharacter); - } else { - processErrorResponse(response, sender); - } - }) + return CompletableFuture + .supplyAsync(request::send, executor) + .thenApply(response -> parseResponse(response)) .exceptionally(e -> { logger.log(Level.SEVERE, "Exception occurred while processing request: " + e.getMessage(), e); - sender.sendMessage(configManager.getChatGPTErrorMessage()); - return null; + return ChatCompletionResult.error(configManager.getChatGPTErrorMessage(), null); }); } - /** - * 处理成功的API响应 - * - * @param response HTTP响应 - * @param sender 命令发送者 - * @param conversationContext 对话上下文 - * @param contextEnabled 是否启用上下文 - * @param currentCharacter 当前角色 - */ - private void processSuccessResponse(HttpResponse response, CommandSender sender, - ConversationContext conversationContext, - boolean contextEnabled, String currentCharacter) { - String responseBody = response.bodyText(); - String utf8ResponseBody = new String(responseBody.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8); - try { - JSONObject jsonResponse = new JSONObject(utf8ResponseBody); - String answer = jsonResponse.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content"); - sender.sendMessage(configManager.getChatGPTResponseMessage().replaceFirst("%s", currentCharacter).replaceFirst("%s", answer)); - if (contextEnabled) { - conversationContext.addMessage(answer); // 仅在启用上下文时添加AI响应到历史记录 + private ChatCompletionResult parseResponse(HttpResponse response) { + if (configManager.isDebugMode()) { + logger.info("Received response: " + response); + } + + int status = response.statusCode(); + String body = response.bodyText(); + + if (status == 200) { + try { + JSONObject json = new JSONObject(body); + String answer = json + .getJSONArray("choices") + .getJSONObject(0) + .getJSONObject("message") + .getString("content"); + + Usage usage = null; + if (json.has("usage")) { + JSONObject usageJson = json.getJSONObject("usage"); + long prompt = usageJson.optLong("prompt_tokens", -1); + long completion = usageJson.optLong("completion_tokens", -1); + long total = usageJson.optLong("total_tokens", -1); + if (prompt >= 0 && completion >= 0 && total >= 0) { + usage = new Usage(prompt, completion, total); + } + } + + return ChatCompletionResult.success(answer, usage); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to parse response: " + e.getMessage(), e); + return ChatCompletionResult.error(configManager.getChatGPTErrorMessage(), body); } - } catch (Exception e) { - logger.log(Level.SEVERE, "Failed to parse ChatGPT response: " + e.getMessage(), e); - sender.sendMessage(configManager.getChatGPTErrorMessage()); } + + String debugDetails = tryExtractOpenAiErrorDetails(status, body); + logger.log(Level.SEVERE, "Failed to get response (HTTP " + status + "): " + body); + return ChatCompletionResult.error(configManager.getChatGPTErrorMessage(), debugDetails); } - /** - * 处理错误的API响应 - * - * @param response HTTP响应 - * @param sender 命令发送者 - */ - private void processErrorResponse(HttpResponse response, CommandSender sender) { - String errorBody = response.bodyText(); - logger.log(Level.SEVERE, "Failed to get a response from ChatGPT: " + errorBody); - sender.sendMessage(configManager.getChatGPTErrorMessage()); - } - - /** - * 将字符串转换为UTF-8编码 - * - * @param input 输入字符串 - * @return UTF-8编码的字符串 - */ - private String convertToUTF8(String input) { + private static String tryExtractOpenAiErrorDetails(int status, String responseBody) { try { - byte[] bytes = input.getBytes(StandardCharsets.UTF_8); - return new String(bytes, StandardCharsets.UTF_8); - } catch (Exception e) { - logger.severe("Failed to convert input to UTF-8: " + e.getMessage()); - return input; // 如果转换失败,返回原始输入 + JSONObject errorJson = new JSONObject(responseBody); + if (!errorJson.has("error")) { + return null; + } + JSONObject err = errorJson.getJSONObject("error"); + String message = err.optString("message", ""); + String code = err.optString("code", ""); + if (message.isEmpty()) { + return null; + } + return "[MineChatGPT] HTTP " + status + (code.isEmpty() ? "" : " (" + code + ")") + ": " + message; + } catch (Exception ignored) { + return null; } } -} \ No newline at end of file + + private static String normalizeBaseUrl(String baseUrl) { + if (baseUrl == null) { + return "https://api.openai.com/v1"; + } + String trimmed = baseUrl.trim(); + if (trimmed.endsWith("/")) { + return trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + + private static ThreadFactory newNamedThreadFactory(String prefix) { + AtomicInteger counter = new AtomicInteger(1); + return runnable -> { + Thread thread = new Thread(runnable, prefix + counter.getAndIncrement()); + thread.setDaemon(true); + return thread; + }; + } + + public static final class Usage { + public final long promptTokens; + public final long completionTokens; + public final long totalTokens; + + public Usage(long promptTokens, long completionTokens, long totalTokens) { + this.promptTokens = promptTokens; + this.completionTokens = completionTokens; + this.totalTokens = totalTokens; + } + } + + public static final class ChatCompletionResult { + public final String answer; + public final Usage usage; + public final String errorMessage; + public final String debugDetails; + + private ChatCompletionResult(String answer, Usage usage, String errorMessage, String debugDetails) { + this.answer = answer; + this.usage = usage; + this.errorMessage = errorMessage; + this.debugDetails = debugDetails; + } + + public static ChatCompletionResult success(String answer, Usage usage) { + return new ChatCompletionResult(answer, usage, null, null); + } + + public static ChatCompletionResult error(String errorMessage, String debugDetails) { + return new ChatCompletionResult(null, null, errorMessage, debugDetails); + } + + public boolean isSuccess() { + return errorMessage == null; + } + } +} diff --git a/src/main/java/com/ddaodan/MineChatGPT/service/ApproxCharTokenEstimator.java b/src/main/java/com/ddaodan/MineChatGPT/service/ApproxCharTokenEstimator.java new file mode 100644 index 0000000..782d509 --- /dev/null +++ b/src/main/java/com/ddaodan/MineChatGPT/service/ApproxCharTokenEstimator.java @@ -0,0 +1,13 @@ +package com.ddaodan.MineChatGPT.service; + +public class ApproxCharTokenEstimator implements TokenEstimator { + @Override + public int estimateTextTokens(String text) { + if (text == null || text.isEmpty()) { + return 0; + } + int chars = text.length(); + return (int) Math.ceil(chars / 4.0); + } +} + diff --git a/src/main/java/com/ddaodan/MineChatGPT/service/CommandService.java b/src/main/java/com/ddaodan/MineChatGPT/service/CommandService.java index 08f7cda..ba344c7 100644 --- a/src/main/java/com/ddaodan/MineChatGPT/service/CommandService.java +++ b/src/main/java/com/ddaodan/MineChatGPT/service/CommandService.java @@ -12,12 +12,12 @@ import java.util.Map; */ public class CommandService { private final ConfigManager configManager; - private final ApiService apiService; + private final RequestCoordinator requestCoordinator; private final UserSessionManager sessionManager; - public CommandService(ConfigManager configManager, ApiService apiService, UserSessionManager sessionManager) { + public CommandService(ConfigManager configManager, RequestCoordinator requestCoordinator, UserSessionManager sessionManager) { this.configManager = configManager; - this.apiService = apiService; + this.requestCoordinator = requestCoordinator; this.sessionManager = sessionManager; } @@ -33,6 +33,7 @@ public class CommandService { return true; } configManager.reloadConfig(); + requestCoordinator.reloadFromConfig(); sender.sendMessage(configManager.getReloadMessage()); return true; } @@ -161,15 +162,40 @@ public class CommandService { return true; } String question = String.join(" ", args); - ConversationContext conversationContext = sessionManager.getConversationContext(userId); - boolean contextEnabled = sessionManager.isContextEnabled(userId); - - if (contextEnabled) { - conversationContext.addMessage(question); - } sender.sendMessage(configManager.getQuestionMessage().replace("%s", question)); - apiService.askChatGPT(sender, question, conversationContext, contextEnabled, userId); + requestCoordinator.submitAsk(sender, question, userId); + return true; + } + + public boolean handleStatsCommand(CommandSender sender, String[] args, String userId) { + if (!sender.hasPermission("minechatgpt.stats")) { + sender.sendMessage(configManager.getNoPermissionMessage().replace("%s", "minechatgpt.stats")); + return true; + } + + if (args.length >= 2 && "reset".equalsIgnoreCase(args[1])) { + requestCoordinator.resetUsageTracker(); + sender.sendMessage(configManager.getStatsResetMessage()); + return true; + } + + UsageTracker.Snapshot global = requestCoordinator.getUsageTracker().getGlobal(); + UsageTracker.Snapshot user = requestCoordinator.getUsageTracker().getUser(userId); + + sender.sendMessage(configManager.getStatsHeaderMessage()); + sender.sendMessage(configManager.getStatsGlobalMessage() + .replaceFirst("%s", String.valueOf(global.totalTokens)) + .replaceFirst("%s", String.valueOf(global.promptTokens)) + .replaceFirst("%s", String.valueOf(global.completionTokens)) + .replaceFirst("%s", String.valueOf(global.requests))); + + sender.sendMessage(configManager.getStatsUserMessage() + .replaceFirst("%s", String.valueOf(user.totalTokens)) + .replaceFirst("%s", String.valueOf(user.promptTokens)) + .replaceFirst("%s", String.valueOf(user.completionTokens)) + .replaceFirst("%s", String.valueOf(user.requests))); + return true; } @@ -187,5 +213,6 @@ public class CommandService { sender.sendMessage(configManager.getHelpContextMessage()); sender.sendMessage(configManager.getHelpClearMessage()); sender.sendMessage(configManager.getHelpCharacterMessage()); + sender.sendMessage(configManager.getHelpStatsMessage()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/ddaodan/MineChatGPT/service/RateLimiter.java b/src/main/java/com/ddaodan/MineChatGPT/service/RateLimiter.java new file mode 100644 index 0000000..91e5720 --- /dev/null +++ b/src/main/java/com/ddaodan/MineChatGPT/service/RateLimiter.java @@ -0,0 +1,110 @@ +package com.ddaodan.MineChatGPT.service; + +import com.ddaodan.MineChatGPT.ConfigManager; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class RateLimiter { + public static final class Decision { + public final boolean allowed; + public final String reason; + public final long retryAfterMs; + + private Decision(boolean allowed, String reason, long retryAfterMs) { + this.allowed = allowed; + this.reason = reason; + this.retryAfterMs = retryAfterMs; + } + + public static Decision allow() { + return new Decision(true, null, 0); + } + + public static Decision deny(String reason, long retryAfterMs) { + return new Decision(false, reason, retryAfterMs); + } + } + + private static final class UserBuckets { + private final TokenBucket requests; + private final TokenBucket tokens; + + private UserBuckets(TokenBucket requests, TokenBucket tokens) { + this.requests = requests; + this.tokens = tokens; + } + } + + private final ConfigManager configManager; + private final Map perUser = new ConcurrentHashMap<>(); + private final Map nextAllowedAt = new ConcurrentHashMap<>(); + + private volatile TokenBucket globalRequests; + private volatile TokenBucket globalTokens; + + public RateLimiter(ConfigManager configManager) { + this.configManager = configManager; + long now = System.currentTimeMillis(); + this.globalRequests = new TokenBucket( + configManager.getGlobalBurstRequests(), + configManager.getGlobalRequestsPerMinute() / 60.0, + now + ); + this.globalTokens = new TokenBucket( + configManager.getGlobalBurstTokens(), + configManager.getGlobalTokensPerMinute() / 60.0, + now + ); + } + + public Decision tryAcquire(String userId, int estimatedTotalTokens, long nowMillis) { + if (!configManager.isRateLimitEnabled()) { + return Decision.allow(); + } + + long cooldownMs = configManager.getRateLimitCooldownMs(); + Long next = nextAllowedAt.get(userId); + if (next != null && nowMillis < next) { + return Decision.deny("cooldown", next - nowMillis); + } + + String mode = configManager.getRateLimitMode(); + boolean limitRequests = "requests".equalsIgnoreCase(mode) || "both".equalsIgnoreCase(mode); + boolean limitTokens = "tokens".equalsIgnoreCase(mode) || "both".equalsIgnoreCase(mode); + + UserBuckets buckets = perUser.computeIfAbsent(userId, id -> { + TokenBucket req = new TokenBucket( + configManager.getPerUserBurstRequests(), + configManager.getPerUserRequestsPerMinute() / 60.0, + nowMillis + ); + TokenBucket tok = new TokenBucket( + configManager.getPerUserBurstTokens(), + configManager.getPerUserTokensPerMinute() / 60.0, + nowMillis + ); + return new UserBuckets(req, tok); + }); + + if (limitRequests) { + if (!globalRequests.tryConsume(1.0, nowMillis) || !buckets.requests.tryConsume(1.0, nowMillis)) { + return Decision.deny("rate_limited", 0); + } + } + + if (limitTokens) { + double cost = Math.max(0, estimatedTotalTokens); + if (!globalTokens.tryConsume(cost, nowMillis) || !buckets.tokens.tryConsume(cost, nowMillis)) { + return Decision.deny("rate_limited", 0); + } + } + + if (cooldownMs > 0) { + nextAllowedAt.put(userId, nowMillis + cooldownMs); + } + + return Decision.allow(); + } +} + diff --git a/src/main/java/com/ddaodan/MineChatGPT/service/RequestCoordinator.java b/src/main/java/com/ddaodan/MineChatGPT/service/RequestCoordinator.java new file mode 100644 index 0000000..2f7e610 --- /dev/null +++ b/src/main/java/com/ddaodan/MineChatGPT/service/RequestCoordinator.java @@ -0,0 +1,404 @@ +package com.ddaodan.MineChatGPT.service; + +import com.ddaodan.MineChatGPT.ConfigManager; +import com.ddaodan.MineChatGPT.ConversationContext; +import com.ddaodan.MineChatGPT.Main; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; + +public class RequestCoordinator { + private static final String SUMMARY_SYSTEM_PROMPT = + "You are a summarization assistant. Summarize the conversation for future context. " + + "Keep it concise, preserve important facts, user preferences, goals, and constraints. " + + "Return ONLY the updated summary text."; + + private final Main plugin; + private final ConfigManager configManager; + private final ApiService apiService; + private final UserSessionManager sessionManager; + private volatile RateLimiter rateLimiter; + private final AtomicInteger inFlight = new AtomicInteger(0); + + private volatile UsageTracker usageTracker; + private volatile TokenEstimator tokenEstimator; + private volatile RequestQueue queue; + private volatile int dispatchTaskId = -1; + + public RequestCoordinator(Main plugin, ConfigManager configManager, ApiService apiService, UserSessionManager sessionManager) { + this.plugin = plugin; + this.configManager = configManager; + this.apiService = apiService; + this.sessionManager = sessionManager; + this.usageTracker = new UsageTracker(); + reloadFromConfig(); + } + + public void reloadFromConfig() { + this.tokenEstimator = createTokenEstimator(configManager.getRateLimitTokenEstimator()); + this.queue = new RequestQueue<>(configManager.getQueueMaxSize(), configManager.getQueueMaxPerUser()); + this.rateLimiter = new RateLimiter(configManager); + } + + public UsageTracker getUsageTracker() { + return usageTracker; + } + + public void resetUsageTracker() { + this.usageTracker = new UsageTracker(); + } + + public void start() { + if (dispatchTaskId != -1) { + return; + } + dispatchTaskId = Bukkit.getScheduler().runTaskTimer(plugin, () -> { + if (!configManager.isQueueEnabled()) { + return; + } + int maxInFlight = configManager.getQueueMaxInFlight(); + int perTick = configManager.getQueueDispatchPerTick(); + for (int i = 0; i < perTick; i++) { + if (inFlight.get() >= maxInFlight) { + return; + } + RequestJob job = queue.poll(); + if (job == null) { + return; + } + dispatch(job); + } + }, 1L, 1L).getTaskId(); + } + + public void stop() { + if (dispatchTaskId != -1) { + Bukkit.getScheduler().cancelTask(dispatchTaskId); + dispatchTaskId = -1; + } + } + + public void submitAsk(CommandSender sender, String question, String userId) { + boolean contextEnabled = sessionManager.isContextEnabled(userId); + ConversationContext context = sessionManager.getConversationContext(userId); + + int estimatedTokensForLimit = estimateTokensForLimit(userId, question, contextEnabled, context); + long now = System.currentTimeMillis(); + RateLimiter.Decision decision = rateLimiter.tryAcquire(userId, estimatedTokensForLimit, now); + if (!decision.allowed) { + if ("cooldown".equals(decision.reason)) { + sender.sendMessage(configManager.getCooldownMessage().replace("%s", String.valueOf(decision.retryAfterMs))); + } else { + sender.sendMessage(configManager.getRateLimitedMessage()); + } + return; + } + + RequestJob job = new RequestJob(sender, question, userId, contextEnabled, estimatedTokensForLimit); + + if (!configManager.isQueueEnabled()) { + int maxInFlight = configManager.getQueueMaxInFlight(); + if (inFlight.get() >= maxInFlight) { + sender.sendMessage(configManager.getQueueFullMessage()); + return; + } + dispatch(job); + return; + } + + RequestQueue.EnqueueResult result = queue.tryEnqueue(userId, job); + if (!result.accepted) { + if ("queue_full_user".equals(result.reason)) { + sender.sendMessage(configManager.getQueueFullUserMessage()); + } else { + sender.sendMessage(configManager.getQueueFullMessage()); + } + return; + } + + sender.sendMessage(configManager.getQueuedMessage().replace("%s", String.valueOf(result.position))); + } + + private void dispatch(RequestJob job) { + inFlight.incrementAndGet(); + process(job) + .whenComplete((ignored, e) -> { + if (e != null) { + plugin.getLogger().log(Level.SEVERE, "Job failed: " + e.getMessage(), e); + } + inFlight.decrementAndGet(); + }); + } + + private CompletableFuture process(RequestJob job) { + if (job.sender instanceof Player) { + Player player = (Player) job.sender; + if (!player.isOnline()) { + return CompletableFuture.completedFuture(null); + } + } + + ConversationContext context = sessionManager.getConversationContext(job.userId); + String characterName = sessionManager.getCurrentCharacter(job.userId); + String characterPrompt = configManager.getCharacters().get(characterName); + String summary = sessionManager.getSummary(job.userId); + + CompletableFuture maybeSummarize = ensureContextWithinBudget(job.userId, job.contextEnabled, characterPrompt, summary, context); + + return maybeSummarize.thenCompose(v -> { + String updatedSummary = sessionManager.getSummary(job.userId); + JSONArray messages = buildMessages(job.contextEnabled, characterPrompt, updatedSummary, context, job.question); + String model = configManager.getCurrentModel(); + return apiService.createChatCompletion(model, messages) + .thenAccept(result -> handleCompletion(job, characterName, result)); + }); + } + + private void handleCompletion(RequestJob job, String characterName, ApiService.ChatCompletionResult result) { + Bukkit.getScheduler().runTask(plugin, () -> { + if (!plugin.isEnabled()) { + return; + } + if (job.sender instanceof Player) { + Player player = (Player) job.sender; + if (!player.isOnline()) { + return; + } + } + + if (!result.isSuccess()) { + job.sender.sendMessage(result.errorMessage); + if (configManager.isDebugMode() && result.debugDetails != null && !result.debugDetails.isEmpty()) { + job.sender.sendMessage(result.debugDetails); + } + return; + } + + String formatted = formatTwoPlaceholders(configManager.getChatGPTResponseMessage(), characterName, result.answer); + sendMultiline(job.sender, formatted); + + if (job.contextEnabled) { + ConversationContext ctx = sessionManager.getConversationContext(job.userId); + ctx.addUserMessage(job.question); + ctx.addAssistantMessage(result.answer); + } + + long promptTokens = 0; + long completionTokens = 0; + long totalTokens = 0; + if (result.usage != null) { + promptTokens = result.usage.promptTokens; + completionTokens = result.usage.completionTokens; + totalTokens = result.usage.totalTokens; + } else { + // fallback estimation + int estimatedPrompt = estimatePromptTokens(job.userId, job.question, job.contextEnabled); + int estimatedCompletion = tokenEstimator.estimateTextTokens(result.answer); + promptTokens = Math.max(0, estimatedPrompt); + completionTokens = Math.max(0, estimatedCompletion); + totalTokens = promptTokens + completionTokens; + } + usageTracker.record(job.userId, promptTokens, completionTokens, totalTokens); + }); + } + + private CompletableFuture ensureContextWithinBudget(String userId, boolean contextEnabled, String characterPrompt, String summary, ConversationContext context) { + if (!contextEnabled) { + return CompletableFuture.completedFuture(null); + } + + if (!configManager.isSummarizationEnabled()) { + trimContextToBudget(characterPrompt, summary, context); + return CompletableFuture.completedFuture(null); + } + + int trigger = configManager.getSummarizationTriggerTokens(); + int estimated = estimateContextTokens(characterPrompt, summary, context); + if (estimated <= trigger) { + trimContextToBudget(characterPrompt, summary, context); + return CompletableFuture.completedFuture(null); + } + + int keepLast = configManager.getSummarizationKeepLastMessages(); + List history = context.getMessages(); + if (history.size() <= keepLast) { + trimContextToBudget(characterPrompt, summary, context); + return CompletableFuture.completedFuture(null); + } + + List toSummarize = history.subList(0, history.size() - keepLast); + List toKeep = history.subList(history.size() - keepLast, history.size()); + + JSONArray summarizeMessages = new JSONArray(); + summarizeMessages.put(new JSONObject().put("role", "system").put("content", SUMMARY_SYSTEM_PROMPT)); + + StringBuilder sb = new StringBuilder(); + String existing = sessionManager.getSummary(userId); + if (existing != null && !existing.trim().isEmpty()) { + sb.append("Current summary:\n").append(existing.trim()).append("\n\n"); + } + sb.append("Conversation to summarize:\n"); + for (ConversationContext.Message msg : toSummarize) { + sb.append(msg.getRole()).append(": ").append(msg.getContent()).append("\n"); + } + summarizeMessages.put(new JSONObject().put("role", "user").put("content", sb.toString())); + + String model = configManager.getSummarizationModel(); + return apiService.createChatCompletion(model, summarizeMessages) + .thenCompose(result -> runSync(() -> { + if (!result.isSuccess()) { + return; + } + String newSummary = result.answer == null ? "" : result.answer.trim(); + sessionManager.setSummary(userId, newSummary); + context.setMessages(new ArrayList<>(toKeep)); + trimContextToBudget(characterPrompt, newSummary, context); + + if (result.usage != null) { + usageTracker.record(userId, result.usage.promptTokens, result.usage.completionTokens, result.usage.totalTokens); + } + })); + } + + private CompletableFuture runSync(Runnable runnable) { + CompletableFuture future = new CompletableFuture<>(); + Bukkit.getScheduler().runTask(plugin, () -> { + try { + runnable.run(); + future.complete(null); + } catch (Exception e) { + future.completeExceptionally(e); + } + }); + return future; + } + + private void trimContextToBudget(String characterPrompt, String summary, ConversationContext context) { + int budget = configManager.getMaxContextTokens(); + if (budget <= 0) { + return; + } + while (estimateContextTokens(characterPrompt, summary, context) > budget) { + List history = context.getMessages(); + if (history.isEmpty()) { + return; + } + context.removeOldestMessage(); + } + } + + private int estimateContextTokens(String characterPrompt, String summary, ConversationContext context) { + int total = 0; + if (characterPrompt != null && !characterPrompt.isEmpty()) { + total += tokenEstimator.estimateMessageTokens("system", characterPrompt); + } + if (summary != null && !summary.trim().isEmpty()) { + total += tokenEstimator.estimateMessageTokens("system", "Conversation summary:\n" + summary.trim()); + } + for (ConversationContext.Message msg : context.getMessages()) { + total += tokenEstimator.estimateMessageTokens(msg.getRole(), msg.getContent()); + } + return total; + } + + private int estimatePromptTokens(String userId, String question, boolean contextEnabled) { + ConversationContext context = sessionManager.getConversationContext(userId); + String characterName = sessionManager.getCurrentCharacter(userId); + String characterPrompt = configManager.getCharacters().get(characterName); + String summary = sessionManager.getSummary(userId); + + int total = 0; + if (characterPrompt != null && !characterPrompt.isEmpty()) { + total += tokenEstimator.estimateMessageTokens("system", characterPrompt); + } + if (contextEnabled) { + if (summary != null && !summary.trim().isEmpty()) { + total += tokenEstimator.estimateMessageTokens("system", "Conversation summary:\n" + summary.trim()); + } + for (ConversationContext.Message msg : context.getMessages()) { + total += tokenEstimator.estimateMessageTokens(msg.getRole(), msg.getContent()); + } + } + total += tokenEstimator.estimateMessageTokens("user", question); + return total; + } + + private int estimateTokensForLimit(String userId, String question, boolean contextEnabled, ConversationContext context) { + int assumedCompletion = configManager.getRateLimitAssumedCompletionTokens(); + int reserve = configManager.getReserveCompletionTokens(); + int completionBudget = Math.max(assumedCompletion, reserve); + int prompt = estimatePromptTokens(userId, question, contextEnabled); + return prompt + completionBudget; + } + + private JSONArray buildMessages(boolean contextEnabled, String characterPrompt, String summary, ConversationContext context, String question) { + JSONArray messages = new JSONArray(); + if (characterPrompt != null && !characterPrompt.isEmpty()) { + messages.put(new JSONObject().put("role", "system").put("content", characterPrompt)); + } + if (contextEnabled) { + if (summary != null && !summary.trim().isEmpty()) { + messages.put(new JSONObject().put("role", "system").put("content", "Conversation summary:\n" + summary.trim())); + } + for (ConversationContext.Message msg : context.getMessages()) { + messages.put(new JSONObject().put("role", msg.getRole()).put("content", msg.getContent())); + } + } + messages.put(new JSONObject().put("role", "user").put("content", question)); + return messages; + } + + private static void sendMultiline(CommandSender sender, String message) { + String[] lines = message.split("\\R", -1); + for (String line : lines) { + if (!line.isEmpty()) { + sender.sendMessage(line); + } + } + } + + private static String formatTwoPlaceholders(String template, String first, String second) { + String withFirst = replaceFirstLiteral(template, "%s", first); + return replaceFirstLiteral(withFirst, "%s", second); + } + + private static String replaceFirstLiteral(String template, String token, String replacement) { + int index = template.indexOf(token); + if (index < 0) { + return template; + } + return template.substring(0, index) + replacement + template.substring(index + token.length()); + } + + private static TokenEstimator createTokenEstimator(String type) { + if ("approx_chars".equalsIgnoreCase(type)) { + return new ApproxCharTokenEstimator(); + } + return new ApproxCharTokenEstimator(); + } + + private static final class RequestJob { + private final CommandSender sender; + private final String question; + private final String userId; + private final boolean contextEnabled; + @SuppressWarnings("unused") + private final int estimatedTokensForLimit; + + private RequestJob(CommandSender sender, String question, String userId, boolean contextEnabled, int estimatedTokensForLimit) { + this.sender = sender; + this.question = question; + this.userId = userId; + this.contextEnabled = contextEnabled; + this.estimatedTokensForLimit = estimatedTokensForLimit; + } + } +} diff --git a/src/main/java/com/ddaodan/MineChatGPT/service/RequestQueue.java b/src/main/java/com/ddaodan/MineChatGPT/service/RequestQueue.java new file mode 100644 index 0000000..57eca68 --- /dev/null +++ b/src/main/java/com/ddaodan/MineChatGPT/service/RequestQueue.java @@ -0,0 +1,83 @@ +package com.ddaodan.MineChatGPT.service; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.Map; + +public class RequestQueue { + public static final class EnqueueResult { + public final boolean accepted; + public final int position; + public final String reason; + + private EnqueueResult(boolean accepted, int position, String reason) { + this.accepted = accepted; + this.position = position; + this.reason = reason; + } + + public static EnqueueResult accepted(int position) { + return new EnqueueResult(true, position, null); + } + + public static EnqueueResult rejected(String reason) { + return new EnqueueResult(false, -1, reason); + } + } + + private final int maxSize; + private final int maxPerUser; + private final Deque> queue = new ArrayDeque<>(); + private final Map perUserCounts = new HashMap<>(); + + private static final class Entry { + private final String userId; + private final T item; + + private Entry(String userId, T item) { + this.userId = userId; + this.item = item; + } + } + + public RequestQueue(int maxSize, int maxPerUser) { + this.maxSize = Math.max(0, maxSize); + this.maxPerUser = Math.max(0, maxPerUser); + } + + public synchronized EnqueueResult tryEnqueue(String userId, T item) { + if (maxSize > 0 && queue.size() >= maxSize) { + return EnqueueResult.rejected("queue_full"); + } + int count = perUserCounts.getOrDefault(userId, 0); + if (maxPerUser > 0 && count >= maxPerUser) { + return EnqueueResult.rejected("queue_full_user"); + } + queue.addLast(new Entry<>(userId, item)); + perUserCounts.put(userId, count + 1); + return EnqueueResult.accepted(queue.size()); + } + + public synchronized T poll() { + Entry entry = queue.pollFirst(); + if (entry == null) { + return null; + } + Integer count = perUserCounts.get(entry.userId); + if (count != null) { + int next = count - 1; + if (next <= 0) { + perUserCounts.remove(entry.userId); + } else { + perUserCounts.put(entry.userId, next); + } + } + return entry.item; + } + + public synchronized int size() { + return queue.size(); + } +} + diff --git a/src/main/java/com/ddaodan/MineChatGPT/service/TokenBucket.java b/src/main/java/com/ddaodan/MineChatGPT/service/TokenBucket.java new file mode 100644 index 0000000..0771e9a --- /dev/null +++ b/src/main/java/com/ddaodan/MineChatGPT/service/TokenBucket.java @@ -0,0 +1,48 @@ +package com.ddaodan.MineChatGPT.service; + +public class TokenBucket { + private final double capacity; + private final double refillTokensPerMillis; + private double tokens; + private long lastRefillMillis; + + public TokenBucket(double capacity, double refillTokensPerSecond, long nowMillis) { + this.capacity = Math.max(0.0, capacity); + this.refillTokensPerMillis = Math.max(0.0, refillTokensPerSecond) / 1000.0; + this.tokens = this.capacity; + this.lastRefillMillis = nowMillis; + } + + public synchronized boolean tryConsume(double amount, long nowMillis) { + refill(nowMillis); + if (amount <= 0) { + return true; + } + if (tokens >= amount) { + tokens -= amount; + return true; + } + return false; + } + + public synchronized void refund(double amount, long nowMillis) { + refill(nowMillis); + if (amount <= 0) { + return; + } + tokens = Math.min(capacity, tokens + amount); + } + + private void refill(long nowMillis) { + if (nowMillis <= lastRefillMillis) { + return; + } + double elapsed = nowMillis - lastRefillMillis; + double add = elapsed * refillTokensPerMillis; + if (add > 0) { + tokens = Math.min(capacity, tokens + add); + } + lastRefillMillis = nowMillis; + } +} + diff --git a/src/main/java/com/ddaodan/MineChatGPT/service/TokenEstimator.java b/src/main/java/com/ddaodan/MineChatGPT/service/TokenEstimator.java new file mode 100644 index 0000000..9b3920a --- /dev/null +++ b/src/main/java/com/ddaodan/MineChatGPT/service/TokenEstimator.java @@ -0,0 +1,11 @@ +package com.ddaodan.MineChatGPT.service; + +public interface TokenEstimator { + int estimateTextTokens(String text); + + default int estimateMessageTokens(String role, String content) { + int base = 4; // message overhead (approx) + return base + estimateTextTokens(role) + estimateTextTokens(content); + } +} + diff --git a/src/main/java/com/ddaodan/MineChatGPT/service/UsageTracker.java b/src/main/java/com/ddaodan/MineChatGPT/service/UsageTracker.java new file mode 100644 index 0000000..e4b852b --- /dev/null +++ b/src/main/java/com/ddaodan/MineChatGPT/service/UsageTracker.java @@ -0,0 +1,72 @@ +package com.ddaodan.MineChatGPT.service; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.LongAdder; + +public class UsageTracker { + public static final class Snapshot { + public final long requests; + public final long promptTokens; + public final long completionTokens; + public final long totalTokens; + + private Snapshot(long requests, long promptTokens, long completionTokens, long totalTokens) { + this.requests = requests; + this.promptTokens = promptTokens; + this.completionTokens = completionTokens; + this.totalTokens = totalTokens; + } + } + + private static final class Counters { + private final LongAdder requests = new LongAdder(); + private final LongAdder promptTokens = new LongAdder(); + private final LongAdder completionTokens = new LongAdder(); + private final LongAdder totalTokens = new LongAdder(); + + private Snapshot snapshot() { + return new Snapshot( + requests.sum(), + promptTokens.sum(), + completionTokens.sum(), + totalTokens.sum() + ); + } + } + + private final Counters global = new Counters(); + private final Map perUser = new ConcurrentHashMap<>(); + + public void record(String userId, long promptTokens, long completionTokens, long totalTokens) { + global.requests.increment(); + global.promptTokens.add(promptTokens); + global.completionTokens.add(completionTokens); + global.totalTokens.add(totalTokens); + + Counters user = perUser.computeIfAbsent(userId, k -> new Counters()); + user.requests.increment(); + user.promptTokens.add(promptTokens); + user.completionTokens.add(completionTokens); + user.totalTokens.add(totalTokens); + } + + public Snapshot getGlobal() { + return global.snapshot(); + } + + public Snapshot getUser(String userId) { + Counters user = perUser.get(userId); + if (user == null) { + return new Snapshot(0, 0, 0, 0); + } + return user.snapshot(); + } + + public void reset() { + perUser.clear(); + // global LongAdder has no reset without recreating + // Create a new UsageTracker if you need a hard reset + } +} + diff --git a/src/main/java/com/ddaodan/MineChatGPT/service/UserSessionManager.java b/src/main/java/com/ddaodan/MineChatGPT/service/UserSessionManager.java index b7653e6..d0e5dc5 100644 --- a/src/main/java/com/ddaodan/MineChatGPT/service/UserSessionManager.java +++ b/src/main/java/com/ddaodan/MineChatGPT/service/UserSessionManager.java @@ -14,12 +14,14 @@ public class UserSessionManager { private final Map userContexts; private final Map userContextEnabled; private final Map userCurrentCharacter; + private final Map userSummaries; public UserSessionManager(ConfigManager configManager) { this.configManager = configManager; this.userContexts = new HashMap<>(); this.userContextEnabled = new HashMap<>(); this.userCurrentCharacter = new HashMap<>(); + this.userSummaries = new HashMap<>(); } /** @@ -67,6 +69,7 @@ public class UserSessionManager { public void clearConversationHistory(String userId) { ConversationContext context = getConversationContext(userId); context.clearHistory(); + clearSummary(userId); } /** @@ -92,4 +95,20 @@ public class UserSessionManager { userCurrentCharacter.put(userId, character); configManager.setCurrentCharacter(userId, character); } -} \ No newline at end of file + + public String getSummary(String userId) { + return userSummaries.getOrDefault(userId, ""); + } + + public void setSummary(String userId, String summary) { + if (summary == null) { + userSummaries.remove(userId); + return; + } + userSummaries.put(userId, summary); + } + + public void clearSummary(String userId) { + userSummaries.remove(userId); + } +} diff --git a/src/main/java/com/ddaodan/MineChatGPT/util/ConfigFileUpdater.java b/src/main/java/com/ddaodan/MineChatGPT/util/ConfigFileUpdater.java new file mode 100644 index 0000000..ace100a --- /dev/null +++ b/src/main/java/com/ddaodan/MineChatGPT/util/ConfigFileUpdater.java @@ -0,0 +1,424 @@ +package com.ddaodan.MineChatGPT.util; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.text.SimpleDateFormat; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +public final class ConfigFileUpdater { + private ConfigFileUpdater() { + } + + public static UpdateResult updateIfMissingKeys(JavaPlugin plugin, String resourcePath) { + File configFile = new File(plugin.getDataFolder(), resourcePath); + if (!configFile.exists()) { + plugin.saveResource(resourcePath, false); + return UpdateResult.noChange(); + } + + try { + YamlConfiguration currentYaml = YamlConfiguration.loadConfiguration(configFile); + YamlConfiguration defaultYaml = loadYamlFromResource(plugin, resourcePath); + if (defaultYaml == null) { + return UpdateResult.noChange(); + } + + List defaultLines = readResourceLines(plugin, resourcePath); + if (defaultLines.isEmpty()) { + return UpdateResult.noChange(); + } + DocumentModel defaultModel = parseDocument(defaultLines); + + List missingTopLevelPaths = collectMissingTopPaths(currentYaml, defaultYaml, defaultModel); + if (missingTopLevelPaths.isEmpty()) { + return UpdateResult.noChange(); + } + + List currentLines = Files.readAllLines(configFile.toPath(), StandardCharsets.UTF_8); + int inserted = 0; + for (String missingPath : missingTopLevelPaths) { + Entry source = defaultModel.entriesByPath.get(missingPath); + if (source == null) { + continue; + } + + List snippet = new ArrayList<>( + defaultLines.subList(source.startLine, source.endLine + 1) + ); + if (snippet.isEmpty()) { + continue; + } + + DocumentModel currentModel = parseDocument(currentLines); + int insertAt = findInsertIndex(currentLines, currentModel, defaultModel, missingPath); + if (insertAt < 0 || insertAt > currentLines.size()) { + insertAt = currentLines.size(); + } + insertSnippetWithSpacing(currentLines, insertAt, snippet); + inserted++; + } + + if (inserted == 0) { + return UpdateResult.noChange(); + } + + File backup = createBackup(configFile); + Files.write(configFile.toPath(), currentLines, StandardCharsets.UTF_8); + return UpdateResult.updated(inserted, backup.getName()); + } catch (Exception ex) { + plugin.getLogger().warning("Failed to auto-update config.yml: " + ex.getMessage()); + return UpdateResult.noChange(); + } + } + + private static YamlConfiguration loadYamlFromResource(JavaPlugin plugin, String resourcePath) { + InputStream inputStream = plugin.getResource(resourcePath); + if (inputStream == null) { + return null; + } + try (Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { + return YamlConfiguration.loadConfiguration(reader); + } catch (IOException ex) { + plugin.getLogger().warning("Failed to read default config resource: " + ex.getMessage()); + return null; + } + } + + private static List collectMissingTopPaths( + YamlConfiguration currentYaml, + YamlConfiguration defaultYaml, + DocumentModel defaultModel + ) { + Set missing = new TreeSet(); + for (String key : defaultYaml.getKeys(true)) { + if (!currentYaml.contains(key)) { + missing.add(key); + } + } + if (missing.isEmpty()) { + return Collections.emptyList(); + } + + List top = new ArrayList(); + for (Entry entry : defaultModel.entriesInOrder) { + String path = entry.path; + if (!missing.contains(path)) { + continue; + } + if (!hasAncestor(top, path)) { + top.add(path); + } + } + return top; + } + + private static boolean hasAncestor(List selected, String path) { + int index = path.lastIndexOf('.'); + while (index > 0) { + String parent = path.substring(0, index); + if (selected.contains(parent)) { + return true; + } + index = parent.lastIndexOf('.'); + } + return false; + } + + private static List readResourceLines(JavaPlugin plugin, String resourcePath) throws IOException { + InputStream inputStream = plugin.getResource(resourcePath); + if (inputStream == null) { + return Collections.emptyList(); + } + List lines = new ArrayList(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + lines.add(line); + } + } + return lines; + } + + private static int findInsertIndex( + List currentLines, + DocumentModel currentModel, + DocumentModel defaultModel, + String path + ) { + String parentPath = getParentPath(path); + int beforeSibling = findNextExistingSiblingStartLine(currentModel, defaultModel, path, parentPath); + if (beforeSibling >= 0) { + return beforeSibling; + } + + if (parentPath == null) { + Entry lastRoot = null; + for (Entry entry : currentModel.entriesInOrder) { + if (getParentPath(entry.path) == null) { + lastRoot = entry; + } + } + return lastRoot == null ? currentLines.size() : lastRoot.endLine + 1; + } + + Entry parent = currentModel.entriesByPath.get(parentPath); + if (parent == null) { + return currentLines.size(); + } + return parent.endLine + 1; + } + + private static int findNextExistingSiblingStartLine( + DocumentModel currentModel, + DocumentModel defaultModel, + String path, + String parentPath + ) { + boolean foundSelf = false; + for (Entry defaultEntry : defaultModel.entriesInOrder) { + if (!isDirectChildOf(defaultEntry.path, parentPath)) { + continue; + } + + if (!foundSelf) { + if (defaultEntry.path.equals(path)) { + foundSelf = true; + } + continue; + } + + Entry existingSibling = currentModel.entriesByPath.get(defaultEntry.path); + if (existingSibling != null) { + return existingSibling.startLine; + } + } + return -1; + } + + private static boolean isDirectChildOf(String path, String parentPath) { + String actualParent = getParentPath(path); + if (parentPath == null) { + return actualParent == null; + } + return parentPath.equals(actualParent); + } + + private static String getParentPath(String path) { + int dot = path.lastIndexOf('.'); + if (dot <= 0) { + return null; + } + return path.substring(0, dot); + } + + private static void insertSnippetWithSpacing(List lines, int insertAt, List snippet) { + if (snippet.isEmpty()) { + return; + } + + // Avoid carrying leading/trailing blank lines from the template snippet. + int from = 0; + int to = snippet.size() - 1; + while (from <= to && snippet.get(from).trim().isEmpty()) { + from++; + } + while (to >= from && snippet.get(to).trim().isEmpty()) { + to--; + } + if (from > to) { + return; + } + + List normalized = new ArrayList(snippet.subList(from, to + 1)); + int index = insertAt; + boolean isTopLevel = isTopLevelSnippet(normalized); + if (isTopLevel + && index > 0 + && !lines.get(index - 1).trim().isEmpty() + && !normalized.get(0).trim().isEmpty() + && (index >= lines.size() || !lines.get(index).trim().isEmpty())) { + lines.add(index, ""); + index++; + } + lines.addAll(index, normalized); + } + + private static boolean isTopLevelSnippet(List snippet) { + for (String line : snippet) { + KeyLine keyLine = parseKeyLine(line); + if (keyLine != null) { + return keyLine.indent == 0; + } + } + return false; + } + + private static File createBackup(File configFile) throws IOException { + String suffix = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).format(new Date()); + File backup = new File(configFile.getParentFile(), configFile.getName() + ".bak." + suffix); + Files.copy(configFile.toPath(), backup.toPath(), StandardCopyOption.REPLACE_EXISTING); + return backup; + } + + private static DocumentModel parseDocument(List lines) { + List ordered = new ArrayList(); + Map byPath = new LinkedHashMap(); + ArrayDeque stack = new ArrayDeque(); + + for (int i = 0; i < lines.size(); i++) { + KeyLine keyLine = parseKeyLine(lines.get(i)); + if (keyLine == null) { + continue; + } + + while (!stack.isEmpty() && keyLine.indent <= stack.peek().indent) { + stack.pop(); + } + + String path = stack.isEmpty() ? keyLine.key : stack.peek().path + "." + keyLine.key; + Entry entry = new Entry(path, keyLine.indent, i); + ordered.add(entry); + byPath.put(path, entry); + stack.push(entry); + } + + for (Entry entry : ordered) { + entry.startLine = findSnippetStartLine(lines, entry.line); + entry.endLine = findValueEndLine(lines, entry.line, entry.indent); + } + + return new DocumentModel(ordered, byPath); + } + + private static int findSnippetStartLine(List lines, int keyLineIndex) { + int start = keyLineIndex; + for (int i = keyLineIndex - 1; i >= 0; i--) { + String trimmed = lines.get(i).trim(); + if (trimmed.startsWith("#")) { + start = i; + continue; + } + break; + } + return start; + } + + private static int findValueEndLine(List lines, int keyLineIndex, int keyIndent) { + int end = keyLineIndex; + for (int i = keyLineIndex + 1; i < lines.size(); i++) { + String line = lines.get(i); + KeyLine keyLine = parseKeyLine(line); + if (keyLine != null && keyLine.indent <= keyIndent) { + break; + } + + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + end = i; + } + } + return end; + } + + private static KeyLine parseKeyLine(String line) { + if (line == null) { + return null; + } + + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) { + return null; + } + + int colon = trimmed.indexOf(':'); + if (colon <= 0) { + return null; + } + + String key = trimmed.substring(0, colon).trim(); + if (key.isEmpty()) { + return null; + } + + int indent = 0; + while (indent < line.length() && line.charAt(indent) == ' ') { + indent++; + } + return new KeyLine(key, indent); + } + + public static final class UpdateResult { + public final boolean updated; + public final int insertedPaths; + public final String backupFileName; + + private UpdateResult(boolean updated, int insertedPaths, String backupFileName) { + this.updated = updated; + this.insertedPaths = insertedPaths; + this.backupFileName = backupFileName; + } + + private static UpdateResult noChange() { + return new UpdateResult(false, 0, ""); + } + + private static UpdateResult updated(int insertedPaths, String backupFileName) { + return new UpdateResult(true, insertedPaths, backupFileName); + } + } + + private static final class DocumentModel { + private final List entriesInOrder; + private final Map entriesByPath; + + private DocumentModel(List entriesInOrder, Map entriesByPath) { + this.entriesInOrder = entriesInOrder; + this.entriesByPath = entriesByPath; + } + } + + private static final class Entry { + private final String path; + private final int indent; + private final int line; + private int startLine; + private int endLine; + + private Entry(String path, int indent, int line) { + this.path = path; + this.indent = indent; + this.line = line; + this.startLine = line; + this.endLine = line; + } + } + + private static final class KeyLine { + private final String key; + private final int indent; + + private KeyLine(String key, int indent) { + this.key = key; + this.indent = indent; + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 53992f2..fe57eea 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -4,7 +4,10 @@ # ====================================================== # Available languages: en, zh # 可用语言:en(英文), zh(中文) +# This only affects plugin prompts, not the model's answer language. +# 该项仅影响插件提示语言,不影响模型回答语言。 language: "en" + # ====================================================== # API Configuration # API 设置 @@ -14,32 +17,58 @@ api: # To obtain an API key, visit https://platform.openai.com/account/api-keys and create a new API key # 你的 OpenAI API key,用于身份验证 # 获取 API key 的方法:访问 https://platform.openai.com/account/api-keys 并创建一个新的 API key + # Placeholder keys such as sk-your_openai_api_key_1 are treated as invalid. + # 占位 key(如 sk-your_openai_api_key_1)会被视为无效。 keys: - "sk-your_openai_api_key_1" # You can add multiple API keys below # 可以添加多个API key # - "sk-your_openai_api_key_2" # - "sk-your_openai_api_key_3" - + # API key selection method: "round_robin" or "random" # Round Robin: Use each API key in turn # Random: Randomly select an API key # API key 选择方法:"round_robin"(轮询)或 "random"(随机) # 轮询:依次使用每个API key # 随机:随机选择一个API key + # Use round_robin for steadier distribution in production. + # 生产环境更推荐 round_robin,分配更均匀。 selection_method: "round_robin" - + # The base URL for the OpenAI API, used to construct requests # If you cannot access the official API, you can use a proxy service # OpenAI API 的基础 URL,用于构建请求 # 如果你无法访问官方API,可以使用代理服务 + # Keep the /v1 path in the URL. + # URL 保留 /v1 路径。 base_url: "https://api.openai.com/v1" + + # HTTP timeouts (milliseconds) + # HTTP 超时(毫秒) + # connect_timeout_ms controls connection phase, timeout_ms controls total request time. + # connect_timeout_ms 控制建连阶段,timeout_ms 控制整次请求耗时。 + # Increase carefully on unstable networks to avoid slow failure feedback. + # 网络不稳定时可适当调大,但过大可能导致失败反馈变慢。 + connect_timeout_ms: 10000 + timeout_ms: 30000 + + # Thread pool size for API requests + # API 请求线程池大小 + # Higher value increases concurrency but also upstream pressure. + # 值越大并发越高,但也会增加上游 API 压力。 + # Tune together with queue.max_in_flight. + # 建议与 queue.max_in_flight 配合调优。 + thread_pool_size: 4 + # ====================================================== # Model Configuration # 模型设置 # ====================================================== # List of supported models # 支持的模型列表 +# This is a selectable whitelist; actual availability depends on your provider. +# 这里只是可选白名单,具体是否可用取决于服务商支持情况。 models: # OpenAI ChatGPT - "gpt-4" @@ -58,7 +87,10 @@ models: # The default model to use # 默认使用的模型 +# Use a cost-effective model as default for daily usage. +# 日常建议选择更稳定且成本更低的模型作为默认模型。 default_model: "gpt-4o-mini" + # ====================================================== # Conversation Settings # 对话设置 @@ -68,13 +100,141 @@ conversation: # When enabled, the plugin will remember the conversation history # 连续对话开关 # 启用时,插件将记住对话历史 + # Enabling improves continuity but increases token usage. + # 开启后连贯性更好,但 token 消耗会增加。 context_enabled: false - + # Maximum number of historical records retained # Increasing this value will consume more memory # 最大历史记录保留数量 # 增加此值将消耗更多内存 + # Usually 8~20 is a practical range. + # 通常 8~20 是较实用的范围。 max_history_size: 10 + + # Token budget for context (approximate) + # 上下文 token 预算(近似) + # Exceeding this budget may trigger trimming or summarization. + # 超过预算会触发上下文裁剪或总结。 + max_context_tokens: 2000 + + # Reserve tokens for the model output (approximate) + # 为模型输出预留 token(近似) + # Too low may truncate replies; too high reduces usable context. + # 值太小可能截断回复,值太大则压缩可用上下文。 + reserve_completion_tokens: 400 + + # Automatic summarization to keep context within budget + # 自动总结以控制上下文长度 + summarization: + # If disabled, long sessions are more likely to hit context limits. + # 关闭后长会话更容易触发上下文超限。 + enabled: true + # Model used for summarization + # 用于总结的模型 + # Prefer a faster/cheaper model for summarization. + # 建议用更快/更便宜的模型做总结。 + model: "gpt-4o-mini" + # When estimated context tokens exceed this value, summarization may run + # 当估算的上下文 tokens 超过该值时可能触发总结 + # Usually set slightly lower than max_context_tokens (e.g. 100~300 lower). + # 通常设为略低于 max_context_tokens(如低 100~300)。 + trigger_tokens: 1800 + # Keep the last N messages (user/assistant) after summarization + # 总结后保留最近 N 条消息(user/assistant) + # Higher value keeps more detail but uses more tokens. + # 值越大保留细节越多,但占用也更高。 + keep_last_messages: 6 + +# ====================================================== +# Rate Limit / Queue Settings +# 限流 / 排队 设置 +# ====================================================== +rate_limit: + # Master switch for cooldown/token-bucket limiting. + # 冷却与令牌桶限流总开关。 + enabled: true + # Mode: "requests" | "tokens" | "both" + # 模式:"requests" | "tokens" | "both" + # requests = limit by request count + # tokens = limit by token budget + # both = enforce both limits (recommended) + # requests = 仅按请求次数 + # tokens = 仅按 token 预算 + # both = 两者同时生效(推荐) + mode: "both" + + # Cooldown per user after accepting a request (milliseconds) + # 每个用户请求冷却(毫秒) + # Helps reduce short burst command spam from one player. + # 用于抑制单玩家短时间刷命令。 + cooldown_ms: 1000 + + # Token estimation method: "approx_chars" + # token 估算方法:"approx_chars" + # This estimator is approximate by design. + # 该估算器是近似估算。 + token_estimator: "approx_chars" + + # If token-mode is enabled, assume this many completion tokens per request for limiting + # 如果启用 token 限流,按该值假设每次回复的 completion tokens(用于限流) + # Increase this if users often ask for long outputs. + # 用户经常要求长回复时可适当提高该值。 + assumed_completion_tokens: 300 + + per_user: + # Per-user request refill rate (requests/min). + # 单用户请求额度恢复速率(每分钟)。 + requests_per_minute: 6 + # Per-user request burst capacity. + # 单用户请求突发容量。 + burst_requests: 3 + # Per-user token refill rate (tokens/min). + # 单用户 token 额度恢复速率(每分钟)。 + tokens_per_minute: 4000 + # Per-user token burst capacity. + # 单用户 token 突发容量。 + burst_tokens: 2000 + + global: + # Global request refill rate (requests/min). + # 全局请求额度恢复速率(每分钟)。 + requests_per_minute: 60 + # Global request burst capacity. + # 全局请求突发容量。 + burst_requests: 20 + # Global token refill rate (tokens/min). + # 全局 token 额度恢复速率(每分钟)。 + tokens_per_minute: 100000 + # Global token burst capacity. + # 全局 token 突发容量。 + burst_tokens: 20000 + +queue: + # If enabled, requests can wait in queue instead of immediate rejection. + # 开启后,请求可进入队列等待,而不是直接拒绝。 + enabled: true + # Maximum queued jobs globally + # 全局队列最大长度 + # New jobs are rejected after this limit. + # 达到上限后新请求会被拒绝。 + max_size: 100 + # Maximum queued jobs per user + # 每个用户队列最大长度 + # Prevent one player from occupying all queue slots. + # 防止单玩家占满队列。 + max_per_user: 3 + # Maximum in-flight (concurrent) API requests + # 同时进行中的 API 请求上限 + # Usually set <= api.thread_pool_size. + # 通常建议不高于 api.thread_pool_size。 + max_in_flight: 4 + # How many jobs to dispatch per tick (Spigot main thread) + # 每 tick 尝试派发多少个任务(主线程) + # Higher value drains queue faster but increases main-thread scheduling pressure. + # 值越大清队越快,但主线程调度压力更高。 + dispatch_per_tick: 2 + # ====================================================== # Character Settings # 角色设置 @@ -86,10 +246,12 @@ characters: # 格式: # 角色名称: "角色提示词" # 提示词将作为系统消息发送给AI - + # Keep prompts clear and concise to avoid unnecessary token overhead. + # 建议提示词简洁明确,避免无谓 token 开销。 + # Default character ChatGPT: "You are a helpful assistant." - + # You can add more characters below # 你可以在下方添加更多角色 # Example: diff --git a/src/main/resources/lang/en.yml b/src/main/resources/lang/en.yml index a390cf7..fd79054 100644 --- a/src/main/resources/lang/en.yml +++ b/src/main/resources/lang/en.yml @@ -11,17 +11,28 @@ messages: help_context: "&e/chatgpt context - Toggle context mode." help_clear: "&e/chatgpt clear - Clear conversation history." help_character: "&e/chatgpt character [character_name] - List or switch to a character." + help_stats: "&e/chatgpt stats - Show token usage statistics." context_toggle: "&eContext is now %s." context_toggle_enabled: "&aenabled" context_toggle_disabled: "&cdisabled" current_model_info: "&eCurrent model: %s. Use /chatgpt model to switch models." model_switch: "&aModel switched to %s" chatgpt_error: "&cFailed to contact ChatGPT." + no_api_key: "&cNo API key configured. Please set api.keys in config.yml." chatgpt_response: "&b%s: %s" question: "&bYou: %s" + queued: "&eYour request has been queued. Position: %s" + queue_full: "&cQueue is full. Please try again later." + queue_full_user: "&cYou have too many pending requests. Please wait." + cooldown: "&cYou're sending requests too fast. Please wait %s ms." + rate_limited: "&cRate limited. Please try again later." + stats_header: "&e===== MineChatGPT Stats =====" + stats_global: "&eGlobal tokens: %s (prompt=%s, completion=%s), requests=%s" + stats_user: "&eYour tokens: %s (prompt=%s, completion=%s), requests=%s" + stats_reset: "&aStats reset." character_switched: "&aSwitched to character: %s" available_characters: "&eAvailable characters:" invalid_character: "&cInvalid character. Use /chatgpt character to list available characters." invalid_model: "&cInvalid model. Use /chatgpt modellist to see available models." available_models: "&eAvailable models:" - no_permission: "&cYou do not have permission to use this command. Required permission: %s" \ No newline at end of file + no_permission: "&cYou do not have permission to use this command. Required permission: %s" diff --git a/src/main/resources/lang/zh.yml b/src/main/resources/lang/zh.yml index 0df8c19..24a894b 100644 --- a/src/main/resources/lang/zh.yml +++ b/src/main/resources/lang/zh.yml @@ -11,17 +11,28 @@ messages: help_context: "&e/chatgpt context - 切换连续对话模式" help_clear: "&e/chatgpt clear - 清空对话历史" help_character: "&e/chatgpt character [character_name] - 列出或切换角色" + help_stats: "&e/chatgpt stats - 查看 token 消耗统计" context_toggle: "&e连续对话模式已%s。" context_toggle_enabled: "&a开启" context_toggle_disabled: "&c关闭" current_model_info: "&e当前模型:%s,输入 /chatgpt model 来切换模型。" model_switch: "&a已切换至模型 %s" chatgpt_error: "&c无法联系ChatGPT。" + no_api_key: "&c未配置 API Key,请在 config.yml 的 api.keys 中设置。" chatgpt_response: "&b%s: %s" question: "&b你: %s" + queued: "&e你的请求已进入队列,当前位置:%s" + queue_full: "&c队列已满,请稍后再试。" + queue_full_user: "&c你有太多等待中的请求,请先等待。" + cooldown: "&c请求过快,请等待 %s 毫秒。" + rate_limited: "&c触发限流,请稍后再试。" + stats_header: "&e===== MineChatGPT 统计 =====" + stats_global: "&e全局 tokens:%s(prompt=%s,completion=%s),请求数=%s" + stats_user: "&e你的 tokens:%s(prompt=%s,completion=%s),请求数=%s" + stats_reset: "&a统计已重置。" character_switched: "&a已切换至角色: %s" available_characters: "&e可用的角色列表:" invalid_character: "&c无效的角色。使用 /chatgpt character 查看所有可用的角色。" invalid_model: "&c模型无效。使用 /chatgpt modellist 查看可用模型。" available_models: "&e可用模型列表:" - no_permission: "&c你没有权限使用这个指令。需要的权限:%s" \ No newline at end of file + no_permission: "&c你没有权限使用这个指令。需要的权限:%s" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b6137c3..13a3d72 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -30,4 +30,7 @@ permissions: default: true minechatgpt.character: description: Allows switching characters - default: true \ No newline at end of file + default: true + minechatgpt.stats: + description: Allows viewing/resetting usage statistics + default: op