From c5801d3215a0969bcf90fb2d882775302cd92f19 Mon Sep 17 00:00:00 2001 From: Michael Peter Christen Date: Mon, 8 Dec 2025 00:13:38 +0100 Subject: added AIShield config page for chat access settings. Also added chat to top menu in case that was wanted by the user. Also added the AILab to the main menu. --- defaults/yacy.init | 15 +- htroot/AILab.html | 16 +- htroot/AIShield_p.html | 204 +++++++++++++++++++++ htroot/ConfigSearchPage_p.html | 2 +- htroot/Status.html | 2 + htroot/env/templates/header.template | 30 ++- htroot/env/templates/simpleSearchHeader.template | 1 + htroot/env/templates/simpleheader.template | 1 + htroot/env/templates/submenuAI.template | 1 + htroot/yacychat.html | 13 +- source/net/yacy/htroot/AILab.java | 17 +- source/net/yacy/htroot/AIShield_p.java | 65 +++++++ source/net/yacy/htroot/yacychat.java | 1 + source/net/yacy/htroot/yacysearchtrailer.java | 6 +- source/net/yacy/http/servlets/RAGProxyServlet.java | 88 ++++++++- .../net/yacy/http/servlets/YaCyDefaultServlet.java | 1 + 16 files changed, 424 insertions(+), 39 deletions(-) create mode 100644 htroot/AIShield_p.html create mode 100644 source/net/yacy/htroot/AIShield_p.java diff --git a/defaults/yacy.init b/defaults/yacy.init index 306ab5969..9efa1ef42 100644 --- a/defaults/yacy.init +++ b/defaults/yacy.init @@ -347,7 +347,7 @@ parser.pdf.individualpages.key=page # These strings appear in the Web Mask of the YACY search client # Set these Strings to cusomize your peer and give any message to # other peer users -promoteSearchPageGreeting = Web Search by the People, for the People +promoteSearchPageGreeting = search... # if the following property is set to true, the network name is used as greeting promoteSearchPageGreeting.useNetworkName = false # the following attributes can be used to define a custom image, alternative text and home page on the search page @@ -1292,8 +1292,8 @@ content.phpbb3.dumpfile = # search engine teaser: an about box in search results # this is only shown, if the about.body is filled -about.headline=Please Help Us -about.body= +about.headline=About +about.body= donation.iframesource=https://yacy.net/include/donate.html donation.iframetarget=env/donate.html @@ -1428,3 +1428,12 @@ ai.system-prompt = You are a smart and helpful chatbot. If possible, use friendl ai.llm-system-prefix = \n\nYou may receive additional expert knowledge in the user prompt after a 'Additional Information' headline to enhance your knowledge. Use it only if applicable. ai.llm-user-prefix = \n\nAdditional Information:\n\nbelow you find a collection of texts that might be useful to generate a response. Do not discuss these documents, just use them to answer the question above.\n\n ai.llm-query-generator-prefix = Make a list of search words with low document frequency for the following prompt; use a JSON Array: +ai.shield.allow-nonlocalhost = false +ai.shield.show-chat-link = false +ai.shield.rate.per-minute = 1 +ai.shield.rate.per-hour = 12 +ai.shield.rate.per-day = 30 +ai.shield.limit-all = false +ai.shield.all.per-minute = 1 +ai.shield.all.per-hour = 12 +ai.shield.all.per-day = 30 diff --git a/htroot/AILab.html b/htroot/AILab.html index cbf330393..caa89d83b 100644 --- a/htroot/AILab.html +++ b/htroot/AILab.html @@ -313,18 +313,18 @@ Needs setup

Define a shield

-

Add guardrails: moderation prompts, content filters, or safety rules that wrap every request.

+

Add guardrails: access rates, grant or deny non-localhost access. Activate the front page link for chat to complete this quest.

-
- Shield definition -
-
- Open shield settings
- Store your shield directives (system prompts, stop words) as properties, then exercise them in chat. -
+
+ Shield definition +
+
+ Open shield settings
+ Store your shield directives (system prompts, stop words) as properties, then exercise them in chat.
+ + + #%env/templates/footer.template%# + + diff --git a/htroot/ConfigSearchPage_p.html b/htroot/ConfigSearchPage_p.html index 381006e24..a8e69849c 100644 --- a/htroot/ConfigSearchPage_p.html +++ b/htroot/ConfigSearchPage_p.html @@ -186,7 +186,7 @@
- +
diff --git a/htroot/Status.html b/htroot/Status.html index e2933c49f..3a7d16e4f 100644 --- a/htroot/Status.html +++ b/htroot/Status.html @@ -224,6 +224,8 @@ + + #%env/templates/footer.template%# diff --git a/htroot/env/templates/header.template b/htroot/env/templates/header.template index b42d9a2c2..360e94e52 100644 --- a/htroot/env/templates/header.template +++ b/htroot/env/templates/header.template @@ -106,7 +106,7 @@
  •  
  • - @@ -149,13 +149,24 @@
  • Please help! We need financial help to move on with the development!
  • + #(navigation-showChatLink)#::
  •  
  • -
  • +
  • + + + +
  • + #(/navigation-showChatLink)# +
  •  
  • +
    diff --git a/htroot/yacychat.html b/htroot/yacychat.html index 26ee71cd1..ff68f18cf 100644 --- a/htroot/yacychat.html +++ b/htroot/yacychat.html @@ -542,8 +542,14 @@ - #%env/templates/header.template%# - #%env/templates/submenuAI.template%# + #(topmenu)# + #%env/templates/embeddedheader.template%# + :: + #%env/templates/simpleheader.template%# + :: + #%env/templates/header.template%# + #%env/templates/submenuAI.template%# + #(/topmenu)#

    YaCy Chat

    Chat with your configured LLM using the local YaCy settings. Requests are sent to the same host that served this page.

    @@ -1103,6 +1109,9 @@ body: JSON.stringify(payload) }); if (!response.ok) { + if (response.status === 429) { + throw new Error('Rate limit reached: please wait and try again. The server is protecting itself from overload.'); + } throw new Error(`Request failed: ${response.status} ${response.statusText}`); } if (!response.body) { diff --git a/source/net/yacy/htroot/AILab.java b/source/net/yacy/htroot/AILab.java index e479f4a39..025bd6541 100644 --- a/source/net/yacy/htroot/AILab.java +++ b/source/net/yacy/htroot/AILab.java @@ -69,20 +69,21 @@ public class AILab { final long indexNeeded = 1000L; final boolean hasIndex = indexDocs >= indexNeeded; - // Shield configuration: treat any non-empty custom value as "ready". - final String shieldDefinition = sb.getConfig("ai.shield.definition", "").trim(); - final boolean hasShield = !shieldDefinition.isEmpty(); + // consider the RAG configuration page visit as completing the RAG quest + final boolean ragVisited = "true".equalsIgnoreCase(sb.getConfig("ui.RAGConfig_p.visited", "false")); + + // Shield configuration: consider the shield page visit as completion + final boolean hasShield = "true".equalsIgnoreCase(sb.getConfig("ui.AIShield_p.visited", "false")); + final boolean frontPageLinkActivated = sb.getConfigBool("ai.shield.show-chat-link", false); prop.put("ailab_inference_status", hasEngine ? "ready" : "pending"); - prop.put("ailab_model_status", hasModel ? "ready" : "pending"); + prop.put("ailab_model_status", hasEngine && hasModel ? "ready" : "pending"); prop.put("ailab_inference_configured", hasEngine ? "1" : "0"); prop.put("ailab_index_status", hasIndex ? "ready" : "pending"); prop.putNum("ailab_index_count", indexDocs); prop.putNum("ailab_index_needed", indexNeeded); - // consider the RAG configuration page visit as completing the RAG quest - final boolean ragVisited = "true".equalsIgnoreCase(sb.getConfig("ui.RAGConfig_p.visited", "false")); - prop.put("ailab_rag_status", (hasRagRole || ragVisited) ? "ready" : "pending"); - prop.put("ailab_shield_status", hasShield ? "ready" : "pending"); + prop.put("ailab_rag_status", hasEngine && hasModel && (hasRagRole || ragVisited) ? "ready" : "pending"); + prop.put("ailab_shield_status", hasEngine && hasModel && hasShield && frontPageLinkActivated ? "ready" : "pending"); return prop; } diff --git a/source/net/yacy/htroot/AIShield_p.java b/source/net/yacy/htroot/AIShield_p.java new file mode 100644 index 000000000..7f000e06a --- /dev/null +++ b/source/net/yacy/htroot/AIShield_p.java @@ -0,0 +1,65 @@ +package net.yacy.htroot; + +import net.yacy.cora.protocol.RequestHeader; +import net.yacy.search.Switchboard; +import net.yacy.server.serverObjects; +import net.yacy.server.serverSwitch; +import net.yacy.http.servlets.RAGProxyServlet; + +public class AIShield_p { + + public static serverObjects respond(@SuppressWarnings("unused") final RequestHeader header, final serverObjects post, final serverSwitch env) { + final Switchboard sb = (Switchboard) env; + final serverObjects prop = new serverObjects(); + + if (post != null) { + final boolean allowNonLocal = post.containsKey("ai.shield.allow-nonlocalhost"); + final boolean showChatLink = post.containsKey("ai.shield.show-chat-link"); + final boolean limitAll = post.containsKey("ai.shield.limit-all"); + + final String perMinute = post.get("ai.shield.rate.per-minute", sb.getConfig("ai.shield.rate.per-minute", "1")).trim(); + final String perHour = post.get("ai.shield.rate.per-hour", sb.getConfig("ai.shield.rate.per-hour", "12")).trim(); + final String perDay = post.get("ai.shield.rate.per-day", sb.getConfig("ai.shield.rate.per-day", "30")).trim(); + + final String allPerMinute = post.get("ai.shield.all.per-minute", sb.getConfig("ai.shield.all.per-minute", "1")).trim(); + final String allPerHour = post.get("ai.shield.all.per-hour", sb.getConfig("ai.shield.all.per-hour", "12")).trim(); + final String allPerDay = post.get("ai.shield.all.per-day", sb.getConfig("ai.shield.all.per-day", "30")).trim(); + + sb.setConfig("ai.shield.allow-nonlocalhost", allowNonLocal); + sb.setConfig("ai.shield.show-chat-link", showChatLink); + sb.setConfig("ai.shield.limit-all", limitAll); + sb.setConfig("ai.shield.rate.per-minute", perMinute); + sb.setConfig("ai.shield.rate.per-hour", perHour); + sb.setConfig("ai.shield.rate.per-day", perDay); + sb.setConfig("ai.shield.all.per-minute", allPerMinute); + sb.setConfig("ai.shield.all.per-hour", allPerHour); + sb.setConfig("ai.shield.all.per-day", allPerDay); + // mark shield definition to show quest completion + sb.setConfig("ai.shield.definition", allowNonLocal ? "enabled" : ""); + } + + // mark page as visited for gamification/quest tracking + sb.setConfig("ui.AIShield_p.visited", "true"); + + prop.put("ai.shield.allow-nonlocalhost", sb.getConfigBool("ai.shield.allow-nonlocalhost", false) ? "1" : "0"); + prop.put("ai.shield.show-chat-link", sb.getConfigBool("ai.shield.show-chat-link", false) ? "1" : "0"); + prop.put("ai.shield.limit-all", sb.getConfigBool("ai.shield.limit-all", false) ? "1" : "0"); + + prop.put("ai.shield.rate.per-minute", sb.getConfig("ai.shield.rate.per-minute", "1")); + prop.put("ai.shield.rate.per-hour", sb.getConfig("ai.shield.rate.per-hour", "12")); + prop.put("ai.shield.rate.per-day", sb.getConfig("ai.shield.rate.per-day", "30")); + + prop.put("ai.shield.all.per-minute", sb.getConfig("ai.shield.all.per-minute", "1")); + prop.put("ai.shield.all.per-hour", sb.getConfig("ai.shield.all.per-hour", "12")); + prop.put("ai.shield.all.per-day", sb.getConfig("ai.shield.all.per-day", "30")); + + // telemetry: counts across all IPs + final long now = System.currentTimeMillis(); + RAGProxyServlet.pruneOldEntries(now); + prop.putNum("telemetry.per-minute", RAGProxyServlet.countAccess(null, RAGProxyServlet.ONE_MINUTE_MS, now)); + prop.putNum("telemetry.per-hour", RAGProxyServlet.countAccess(null, RAGProxyServlet.ONE_HOUR_MS, now)); + prop.putNum("telemetry.per-day", RAGProxyServlet.countAccess(null, RAGProxyServlet.ONE_DAY_MS, now)); + + return prop; + } +} diff --git a/source/net/yacy/htroot/yacychat.java b/source/net/yacy/htroot/yacychat.java index df573c7c1..43b6107b5 100644 --- a/source/net/yacy/htroot/yacychat.java +++ b/source/net/yacy/htroot/yacychat.java @@ -29,6 +29,7 @@ public class yacychat { // system prompt comes from configuration; default is empty final String systemPrompt = sb.getConfig("ai.system-prompt", net.yacy.http.servlets.RAGProxyServlet.LLM_SYSTEM_PROMPT_DEFAULT); prop.put("system_prompt", systemPrompt); + prop.put("topmenu", sb.getConfigBool("ai.shield.show-chat-link", false) ? (sb.getConfigBool("publicTopmenu", true) ? 1 : 0) : 2); // return rewrite properties return prop; diff --git a/source/net/yacy/htroot/yacysearchtrailer.java b/source/net/yacy/htroot/yacysearchtrailer.java index 3b9e7a266..c3d7e67ea 100644 --- a/source/net/yacy/htroot/yacysearchtrailer.java +++ b/source/net/yacy/htroot/yacysearchtrailer.java @@ -454,9 +454,9 @@ public class yacysearchtrailer { prop.put("navs", ni); // navigatior plugins - eof // about box - final String aboutBody = env.getConfig("about.body", ""); - final String aboutHeadline = env.getConfig("about.headline", ""); - if ((aboutBody.isEmpty() && aboutHeadline.isEmpty()) || theSearch.getResultCount() == 0) { + final String aboutBody = env.getConfig("about.body", "").trim(); + final String aboutHeadline = env.getConfig("about.headline", "").trim(); + if (aboutBody.isEmpty() || aboutHeadline.isEmpty() || theSearch.getResultCount() == 0) { prop.put("nav-about", 0); } else { prop.put("nav-about", 1); diff --git a/source/net/yacy/http/servlets/RAGProxyServlet.java b/source/net/yacy/http/servlets/RAGProxyServlet.java index 3c8fe535d..499cb1a07 100644 --- a/source/net/yacy/http/servlets/RAGProxyServlet.java +++ b/source/net/yacy/http/servlets/RAGProxyServlet.java @@ -29,10 +29,12 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Comparator; +import java.util.Deque; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashSet; @@ -41,6 +43,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.stream.Collectors; import javax.servlet.ServletException; @@ -92,6 +95,13 @@ public class RAGProxyServlet extends HttpServlet { private static final String LLM_USER_PREFIX_DEFAULT = "\n\nAdditional Information:\n\nbelow you find a collection of texts that might be useful to generate a response. Do not discuss these documents, just use them to answer the question above.\n\n"; private static final String LLM_QUERY_GENERATOR_PREFIX_DEFAULT = "Make a list of search words with low document frequency for the following prompt; use a JSON Array: "; + // Volatile, in-memory access log for rate limiting. This is intentionally not persisted + // to respect user privacy; entries older than 24h are purged on each access. + public static final Deque> ACCESS_LOG = new ConcurrentLinkedDeque<>(); + public static final long ONE_MINUTE_MS = 60_000L; + public static final long ONE_HOUR_MS = 60 * ONE_MINUTE_MS; + public static final long ONE_DAY_MS = 24 * ONE_HOUR_MS; + @Override public void service(ServletRequest request, ServletResponse response) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); @@ -104,13 +114,23 @@ public class RAGProxyServlet extends HttpServlet { hresponse.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE"); hresponse.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + final Switchboard sb = Switchboard.getSwitchboard(); final String clientIP = hrequest.getRemoteAddr(); final boolean localhostAccess = Domains.isLocalhost(clientIP); if (!localhostAccess) { - // we will introduce a rate limit for non-localhost later, for now we just don't allow it - hresponse.sendError(HttpServletResponse.SC_FORBIDDEN); + // obey the allow-nonlocalhost shield setting + final boolean allowNonLocal = sb.getConfigBool("ai.shield.allow-nonlocalhost", false); + if (!allowNonLocal) { + hresponse.sendError(HttpServletResponse.SC_FORBIDDEN); + return; + } } - + if (isRateLimited(sb, clientIP, localhostAccess)) { + hresponse.sendError(429, "Too Many Requests"); // standard status for rate limits + return; + } + recordAccess(clientIP); + final Method reqMethod = Method.getMethod(hrequest.getMethod()); if (reqMethod == Method.OTHER) { // required to handle CORS @@ -138,7 +158,6 @@ public class RAGProxyServlet extends HttpServlet { String body = bodyBuilder.toString(); JSONObject bodyObject; try { - final Switchboard sb = Switchboard.getSwitchboard(); // get system message and user prompt bodyObject = new JSONObject(body); // get chat functions @@ -664,7 +683,7 @@ public class RAGProxyServlet extends HttpServlet { } private String searchWordsForPrompt(LLM llm, String model, String prompt) { - String question = LLM_QUERY_GENERATOR_PREFIX_DEFAULT + prompt; + String question = prompt; try { LLM.Context context = new LLM.Context(LLM_SYSTEM_PREFIX_DEFAULT); context.addPrompt(question); @@ -714,4 +733,63 @@ public class RAGProxyServlet extends HttpServlet { return j; } + public static void pruneOldEntries(long now) { + while (true) { + final AbstractMap.SimpleEntry head = ACCESS_LOG.peekFirst(); + if (head == null) break; + if (now - head.getKey() > ONE_DAY_MS) { + ACCESS_LOG.pollFirst(); + } else { + break; + } + } + } + + public static void recordAccess(String ip) { + final long now = System.currentTimeMillis(); + pruneOldEntries(now); + ACCESS_LOG.addLast(new AbstractMap.SimpleEntry<>(now, ip)); + } + + public static long countAccess(String ip, long windowMillis, long now) { + return ACCESS_LOG.stream() + .filter(e -> (ip == null || e.getValue().equals(ip)) && (now - e.getKey()) <= windowMillis) + .count(); + } + + public static boolean isRateLimited(Switchboard sb, String ip, boolean localhostAccess) { + final long now = System.currentTimeMillis(); + pruneOldEntries(now); + boolean allow_nonlocalhost = sb.getConfigBool("ai.shield.allow-nonlocalhost", false); + boolean limit_all = sb.getConfigBool("ai.shield.limit-all", false); + + // guest limits apply only to non-localhost + if (!localhostAccess) { + long perMinuteLimit = allow_nonlocalhost ? parseLimit(sb.getConfig("ai.shield.rate.per-minute", "0")) : 0; + long perHourLimit = allow_nonlocalhost ? parseLimit(sb.getConfig("ai.shield.rate.per-hour", "0")) : 0; + long perDayLimit = allow_nonlocalhost ? parseLimit(sb.getConfig("ai.shield.rate.per-day", "0")) : 0; + if (perMinuteLimit > 0 && countAccess(ip, ONE_MINUTE_MS, now) >= perMinuteLimit) return true; + if (perHourLimit > 0 && countAccess(ip, ONE_HOUR_MS, now) >= perHourLimit) return true; + if (perDayLimit > 0 && countAccess(ip, ONE_DAY_MS, now) >= perDayLimit) return true; + } + + if (localhostAccess && limit_all) { + long allMinute = parseLimit(sb.getConfig("ai.shield.all.per-minute", "0")); + long allHour = parseLimit(sb.getConfig("ai.shield.all.per-hour", "0")); + long allDay = parseLimit(sb.getConfig("ai.shield.all.per-day", "0")); + if (allMinute > 0 && countAccess(null, ONE_MINUTE_MS, now) >= allMinute) return true; + if (allHour > 0 && countAccess(null, ONE_HOUR_MS, now) >= allHour) return true; + if (allDay > 0 && countAccess(null, ONE_DAY_MS, now) >= allDay) return true; + } + return false; + } + + private static long parseLimit(String value) { + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return 0L; + } + } + } diff --git a/source/net/yacy/http/servlets/YaCyDefaultServlet.java b/source/net/yacy/http/servlets/YaCyDefaultServlet.java index de9ecfcce..afd2e577f 100644 --- a/source/net/yacy/http/servlets/YaCyDefaultServlet.java +++ b/source/net/yacy/http/servlets/YaCyDefaultServlet.java @@ -1143,6 +1143,7 @@ public class YaCyDefaultServlet extends HttpServlet { templatePatterns.put("navigation-crawlmonitor_authorized", authorized ? 1 : 0); templatePatterns.put("navigation-advanced", advanced_enabled); templatePatterns.put("navigation-advanced_authorized", authorized ? 1 : 0); + templatePatterns.put("navigation-showChatLink", sb.getConfigBool("ai.shield.show-chat-link", false) ? "1" : "0"); templatePatterns.put(SwitchboardConstants.GREETING_HOMEPAGE, sb.getConfig(SwitchboardConstants.GREETING_HOMEPAGE, "")); templatePatterns.put(SwitchboardConstants.GREETING_SMALL_IMAGE, sb.getConfig(SwitchboardConstants.GREETING_SMALL_IMAGE, "")); templatePatterns.put(SwitchboardConstants.GREETING_IMAGE_ALT, sb.getConfig(SwitchboardConstants.GREETING_IMAGE_ALT, "")); -- cgit v1.2.3