From 36b581f60bfdf16a7976205c799d6b28812de7ad Mon Sep 17 00:00:00 2001 From: mbichara Date: Thu, 25 Sep 2025 10:56:43 -0300 Subject: [PATCH 001/143] feature #2641: AISummarization for WhatsApp chats - first commit --- .../config/conf/AISummarizationConfig.txt | 11 ++ .../resources/config/conf/TaskInstaller.xml | 1 + .../scripts/tasks/AISummarizationTask.py | 152 ++++++++++++++++++ .../java/iped/app/ui/ViewerController.java | 61 ++++++- .../main/java/iped/viewers/SummaryViewer.java | 124 ++++++++++++++ 5 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 iped-app/resources/config/conf/AISummarizationConfig.txt create mode 100644 iped-app/resources/scripts/tasks/AISummarizationTask.py create mode 100644 iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java diff --git a/iped-app/resources/config/conf/AISummarizationConfig.txt b/iped-app/resources/config/conf/AISummarizationConfig.txt new file mode 100644 index 0000000000..31cc434dad --- /dev/null +++ b/iped-app/resources/config/conf/AISummarizationConfig.txt @@ -0,0 +1,11 @@ +############################################################################## +# Configuration file for AI-based summarization of evidence items. +############################################################################## + +# IP:PORT of the service/central node used by the AISummarizationTask implementation. +remoteServiceAddress = 127.0.0.1:11111 + +# Enables automatic summarization of WhatsApp chats using AI models. +# When set to true, summaries will be generated for WhatsApp conversations parsed by IPED's internal parser. +enableWhatsAppSummarization = false + diff --git a/iped-app/resources/config/conf/TaskInstaller.xml b/iped-app/resources/config/conf/TaskInstaller.xml index a8ba86f0b0..b4a0e2ab10 100644 --- a/iped-app/resources/config/conf/TaskInstaller.xml +++ b/iped-app/resources/config/conf/TaskInstaller.xml @@ -25,6 +25,7 @@ + diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py new file mode 100644 index 0000000000..7e82b71912 --- /dev/null +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -0,0 +1,152 @@ +import requests +#need to install requests lib: .\target\release\iped-4.3.0-snapshot\python\python.exe .\target\release\iped-4.3.0-snapshot\python\get-pip.py requests +#also numpy for some reason: .\target\release\iped-4.3.0-snapshot\python\python.exe .\target\release\iped-4.3.0-snapshot\python\get-pip.py "numpy<2.0" +import json + + + +# configuration properties +enableProp = 'enableAISummarization' +enableWhatsAppSummarizationProp = 'enableWhatsAppSummarization' +configFile = 'AISummarizationConfig.txt' +remoteServiceAddressProp = 'remoteServiceAddress' + + +def create_summaries_request(chat_content: str, base_url: str = "127.0.0.1:1111"): + """ + Sends a POST request to the /api/create_summaries endpoint with chat content. + + Args: + chat_content (str): The chat content to be summarized. + base_url (str): The base URL, defaults to "http://localhost:1111" for local development. + + Returns: + dict: The JSON response from the API, or an error message. + """ + url = f"http://{base_url}/api/create_summaries" + headers = {"Content-Type": "application/json"} + payload = {"chat_content": chat_content} + + try: + response = requests.post(url, headers=headers, data=json.dumps(payload)) + response.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx) + return response.json() + except requests.exceptions.HTTPError as http_err: + #print(f"HTTP error occurred: {http_err}") + #print(f"Response content: {response.text}") + return {"error": f"HTTP error: {http_err}-{response.text}"} + except requests.exceptions.ConnectionError as conn_err: + #print(f"Connection error occurred: {conn_err}") + return {"error": f"Connection error: {conn_err}"} + except requests.exceptions.Timeout as timeout_err: + #print(f"Timeout error occurred: {timeout_err}") + return {"error": f"Timeout error: {timeout_err}"} + except requests.exceptions.RequestException as req_err: + #print(f"An unexpected error occurred: {req_err}") + return {"error": f"An unexpected error: {req_err}"} + except json.JSONDecodeError as json_err: + #print(f"JSON decode error: {json_err}") + #print(f"Raw response: {response.text}") + return {"error": f"JSON decode error: {json_err} - {response.text}"} + + + + +# The main class name must be equal to the script file name without .py extension +# One instance of this class is created by each processing thread and each thread calls the implemented methods of its own object. +class AISummarizationTask: + + enabled = False + remoteServiceAddress = None + enableWhatsAppSummarization = False + + def isEnabled(self): + return AISummarizationTask.enabled + + + def __init__(self): + return + + # Returns if this task is enabled or not. This could access options read by init() method. + def isEnabled(self): + return AISummarizationTask.enabled + + # Returns an optional list of configurable objects that can load/save parameters from/to config files. + def getConfigurables(self): + from iped.engine.config import DefaultTaskPropertiesConfig + return [DefaultTaskPropertiesConfig(enableProp, configFile)] + + # Do some task initialization, like reading options, custom config files or model. + # It is executed when application starts by each processing thread on its own class instance. + def init(self, configuration): + taskConfig = configuration.getTaskConfigurable(configFile) + AISummarizationTask.enabled = taskConfig.isEnabled() + + if not AISummarizationTask.enabled: + return + + extraProps = taskConfig.getConfiguration() + + AISummarizationTask.remoteServiceAddress = extraProps.getProperty(remoteServiceAddressProp) + + if not AISummarizationTask.remoteServiceAddress: + print('[AISummarizationTask]: Error: task enabled but remoteServiceAddress not set on config file.') + return + + AISummarizationTask.enableWhatsAppSummarization = extraProps.getProperty(enableWhatsAppSummarizationProp) + + return + + + + def processWhatsAppChat(self, item): + # Only process WhatsApp chats processed by internal IPED parser for now + if not "whatsapp-chat" in item.getMediaType().toString(): + return + + from iped.properties import ExtraProperties + + chunk_summaries = item.getExtraAttribute(ExtraProperties.SUMMARIES) + if chunk_summaries is not None: + return + + inputStream = item.getBufferedInputStream() + + raw_bytes = inputStream.readAllBytes() + # Ensure bytes are in valid range + valid_bytes = bytes(b & 0xFF for b in raw_bytes) + chatHtml = valid_bytes.decode('utf-8', errors='replace') + + inputStream.close() + + result = create_summaries_request(chatHtml, AISummarizationTask.remoteServiceAddress) + err = result.get('error') + if err: + print(f"[AISummarizationTask]: {item.getName()} - {err}") + return + + #print(result['summaries']) + summaries = result['summaries'] + + #For testing, just append some strings to the summaries + #summaries = [] + #summaries.append('adas asda gfdfg', 'sdfs sdf sdfsda') + + item.setExtraAttribute(ExtraProperties.SUMMARIES, summaries) + + + + + # Process an Item object. This method is executed on all case items. + # It can access any method of Item class and store results as a new extra attribute. + def process(self, item): + + if AISummarizationTask.enableWhatsAppSummarization: + self.processWhatsAppChat(item) + + + + # Called when task processing is finished. Can be used to cleanup resources. + # Objects "ipedCase" and "searcher" are provided, so case can be queried for items and bookmarks can be created. + def finish(self): + return \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ViewerController.java b/iped-app/src/main/java/iped/app/ui/ViewerController.java index 33fd6e7147..4ec9fa0011 100644 --- a/iped-app/src/main/java/iped/app/ui/ViewerController.java +++ b/iped-app/src/main/java/iped/app/ui/ViewerController.java @@ -26,6 +26,7 @@ import bibliothek.gui.dock.common.DefaultSingleCDockable; import bibliothek.gui.dock.common.action.CButton; import bibliothek.gui.dock.common.mode.ExtendedMode; +import bibliothek.gui.dock.common.CControl; import iped.app.ui.controls.CSelButton; import iped.app.ui.viewers.AttachmentSearcherImpl; import iped.app.ui.viewers.HexSearcherImpl; @@ -51,6 +52,7 @@ import iped.viewers.MetadataViewer; import iped.viewers.MsgViewer; import iped.viewers.MultiViewer; +import iped.viewers.SummaryViewer; import iped.viewers.ReferencedFileViewer; import iped.viewers.TiffViewer; import iped.viewers.api.AbstractViewer; @@ -99,6 +101,7 @@ public boolean isNumeric(String field) { return IndexItem.isNumeric(field); } }); + viewers.add(new SummaryViewer()); viewers.add(viewersRepository = new MultiViewer()); // These are content-specific viewers (inside a single ViewersRepository) @@ -299,11 +302,63 @@ public boolean hasHits() { return highlightTerms != null && !highlightTerms.isEmpty(); } + // helper + private boolean isRegistered(DefaultSingleCDockable d) { + CControl cc = App.get().dockingControl; + for (int i = 0; i < cc.getCDockableCount(); i++) { + if (cc.getCDockable(i) == d) return true; + } + return false; + } + + public void updateViewer(AbstractViewer viewer, boolean clean, boolean forceLoad) { + + DefaultSingleCDockable dock = dockPerViewer.get(viewer); + + if (viewer instanceof SummaryViewer) { + boolean has = file != null && ((SummaryViewer) viewer).hasSummaries(file); + CControl cc = App.get().dockingControl; + boolean registered = dock != null && isRegistered(dock); + + if (!has) { + if (registered) { + // ensure some other tab is selected, then remove Summary from the control + AbstractViewer keep = getBestViewer(viewType != null ? viewType : contentType); + DefaultSingleCDockable keepDock = dockPerViewer.get(keep); + if (keepDock != null && keepDock != dock) App.get().selectDockableTab(keepDock); + + SwingUtilities.invokeLater(() -> { + // remove instead of setVisible(false) to prevent the blank stub + cc.removeDockable(dock); + }); + } + if (clean && isInitialized()) viewer.loadFile(null); + return; // done with Summary + } else { + if (!registered) { + // add back and place it alongside the current best viewer + DefaultSingleCDockable keepDock = dockPerViewer.get(getBestViewer(viewType != null ? viewType : contentType)); + SwingUtilities.invokeLater(() -> { + cc.addDockable(dock); + if (keepDock != null) dock.setLocationsAside(keepDock); + dock.setVisible(true); + // keep selection on preview/metadata/etc. + if (keepDock != null) App.get().selectDockableTab(keepDock); + }); + } else if (!dock.isVisible()) { + dock.setVisible(true); + } + // do NOT return; let the generic branch load Summary only when its panel is showing + } + } + // --- end Summary block --- + + if (viewer.getPanel().isShowing() || (viewer.equals(textViewer) && hasHits()) || forceLoad) { if (isInitialized()) loadInViewer(viewer); - DefaultSingleCDockable dock = dockPerViewer.get(viewer); + //DefaultSingleCDockable dock = dockPerViewer.get(viewer); if (dock != null) { boolean hitsEnabled = viewFile != null && ((hasHits() && viewer.getHitsSupported() == 0) || (viewer.getHitsSupported() == 1)); @@ -377,6 +432,10 @@ private void updateViewType() { private AbstractViewer getBestViewer(String contentType) { AbstractViewer result = null; for (AbstractViewer viewer : viewers) { + + // Never make Summary the default tab + if (viewer instanceof iped.viewers.SummaryViewer) continue; + if (viewer.isSupportedType(contentType, true)) { if (viewer instanceof MetadataViewer) { if (((MetadataViewer) viewer).isMetadataEntry(contentType)) { diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java new file mode 100644 index 0000000000..90d49bbbb9 --- /dev/null +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -0,0 +1,124 @@ +package iped.viewers; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Set; + +import iped.data.IItemReader; +import iped.io.IStreamSource; +import iped.properties.ExtraProperties; +import iped.utils.SimpleHTMLEncoder; +import iped.utils.UiUtil; + +/** + * Shows ExtraProperties.SUMMARIES (array of strings) for the current item. + * Extends HtmlViewer to reuse hit highlighting & WebView plumbing. + */ +public class SummaryViewer extends HtmlViewer { + + public SummaryViewer() { + super(); // delegates to HtmlViewer constructor + } + + @Override public String getName() { return "Summary"; } // or wire into i18n later + @Override public boolean isSupportedType(String contentType) { return true; } // we gate by data presence + @Override public int getHitsSupported() { return -1; } // no Prev/Next hit buttons for this tab + + /** Quick presence check so controller can decide tab visibility. */ + public boolean hasSummaries(IStreamSource content) { + if (!(content instanceof IItemReader)) return false; + IItemReader item = (IItemReader) content; + + // Check extra attributes (preferred) + Object v = item.getExtraAttributeMap().get(ExtraProperties.SUMMARIES); + if (v instanceof Collection && !((Collection) v).isEmpty()) return true; + if (v instanceof Object[] && ((Object[]) v).length > 0) return true; + if (v instanceof String && !((String) v).isEmpty()) return true; + if (v != null) return true; // some non-null single value + + // Fallback: metadata bag + String[] vals = item.getMetadata().getValues(ExtraProperties.SUMMARIES); + return vals != null && vals.length > 0; + } + + @Override + public void loadFile(final IStreamSource content, final Set terms) { + loadFile(content, null, terms); + } + + + @Override + public void loadFile(final IStreamSource content, String contentType, final Set terms) { + // Reuse HtmlViewer's highlighter: set highlightTerms and load HTML directly in the WebEngine. + this.highlightTerms = terms; + this.tmpFile = null; // ensure the "location endsWith(tmpFile)" early-return never triggers + + javafx.application.Platform.runLater(() -> { + if (!(content instanceof IItemReader) || !hasSummaries(content)) { + webEngine.loadContent(UiUtil.getUIEmptyHtml()); + return; + } + + IItemReader item = (IItemReader) content; + //List chunks = new ArrayList<>(); + //ArrayList chunks = new ArrayList<>(); + ArrayList chunks = new ArrayList<>(); + + + Object value = item.getExtraAttributeMap().get(ExtraProperties.SUMMARIES); + if (value instanceof Collection) { + for (Object v : (Collection) value) { + if (v != null) chunks.add(v.toString()); + } + } else if (value instanceof Object[]) { + for (Object v : Arrays.asList((Object[]) value)) { + if (v != null) chunks.add(v.toString()); + } + } else if (value instanceof String) { + chunks.add((String) value); + } else if (value != null) { + chunks.add(value.toString()); + } + + // Fallback to metadata if we still have nothing + if (chunks.isEmpty()) { + String[] vals = item.getMetadata().getValues(ExtraProperties.SUMMARIES); + if (vals != null) { + for (String s : vals) { + if (s != null) chunks.add(s); + } + } + } + + + + // Simple, readable HTML; HtmlViewer will highlight search terms after load. + StringBuilder html = new StringBuilder(); + html.append("") + .append("") + .append(""); + + html.append("
AI-generated summaries. Check all information").append("
"); + + for (int i = 0; i < chunks.size(); i++) { + //String c = SimpleHTMLEncoder.htmlEncode(chunks.get(i)); + String c = SimpleHTMLEncoder.htmlEncode(chunks.get(i)).replaceAll("\n","
"); + html.append("
") + //.append("
Chunk ").append(i + 1).append("
") + .append("
").append(c).append("
") + .append("
"); + } + html.append(""); + + // Keep IPED HTML style for consistent colors (HtmlViewer uses this). + webEngine.setUserStyleSheetLocation(UiUtil.getUIHtmlStyle()); + webEngine.setJavaScriptEnabled(false); // not needed here + webEngine.loadContent(html.toString()); + }); + } +} From adacca508e16281dfbf47e10f5b6965c37575e54 Mon Sep 17 00:00:00 2001 From: mbichara Date: Tue, 7 Oct 2025 13:00:26 -0300 Subject: [PATCH 002/143] #2641: add suport to UFED chats: mime x-ufed-chat-preview --- .../scripts/tasks/AISummarizationTask.py | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index 7e82b71912..9d0c9a68cc 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -7,7 +7,8 @@ # configuration properties enableProp = 'enableAISummarization' -enableWhatsAppSummarizationProp = 'enableWhatsAppSummarization' +enableWhatsAppSummarizationProp = 'enableWhatsAppSummarization' # This is for IPED internal Parser +enableUFEDChatSummarizationProp = 'enableUFEDChatSummarization' # This is for UFED Chat Parser - x-ufed-chat-preview configFile = 'AISummarizationConfig.txt' remoteServiceAddressProp = 'remoteServiceAddress' @@ -59,6 +60,7 @@ class AISummarizationTask: enabled = False remoteServiceAddress = None enableWhatsAppSummarization = False + enableUFEDChatSummarization = False def isEnabled(self): return AISummarizationTask.enabled @@ -94,15 +96,13 @@ def init(self, configuration): return AISummarizationTask.enableWhatsAppSummarization = extraProps.getProperty(enableWhatsAppSummarizationProp) + AISummarizationTask.enableUFEDChatSummarization = extraProps.getProperty(enableUFEDChatSummarizationProp) return - - def processWhatsAppChat(self, item): - # Only process WhatsApp chats processed by internal IPED parser for now - if not "whatsapp-chat" in item.getMediaType().toString(): - return + def processChat(self, item): + from iped.properties import ExtraProperties @@ -140,9 +140,18 @@ def processWhatsAppChat(self, item): # Process an Item object. This method is executed on all case items. # It can access any method of Item class and store results as a new extra attribute. def process(self, item): - - if AISummarizationTask.enableWhatsAppSummarization: - self.processWhatsAppChat(item) + if not AISummarizationTask.enabled: + return + + # WhatsApp chats processed by internal IPED parser + if AISummarizationTask.enableWhatsAppSummarization and "whatsapp-chat" in item.getMediaType().toString(): + self.processChat(item) + return + # Process UFED Chats + if AISummarizationTask.enableUFEDChatSummarization and "x-ufed-chat-preview" in item.getMediaType().toString(): + self.processChat(item) + return + From 6bf7c7217e5604c34bb636d26a2febbb729cebe9 Mon Sep 17 00:00:00 2001 From: mbichara Date: Tue, 7 Oct 2025 14:28:02 -0300 Subject: [PATCH 003/143] #2641 add missing config prop --- iped-app/resources/config/conf/AISummarizationConfig.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/iped-app/resources/config/conf/AISummarizationConfig.txt b/iped-app/resources/config/conf/AISummarizationConfig.txt index 31cc434dad..968e9cfa28 100644 --- a/iped-app/resources/config/conf/AISummarizationConfig.txt +++ b/iped-app/resources/config/conf/AISummarizationConfig.txt @@ -9,3 +9,5 @@ remoteServiceAddress = 127.0.0.1:11111 # When set to true, summaries will be generated for WhatsApp conversations parsed by IPED's internal parser. enableWhatsAppSummarization = false +# Enables automatic summarization of UFED supported chats using AI models. +enableUFEDChatSummarization = false \ No newline at end of file From 029dd915742c758e44d4159adc8334739497cc74 Mon Sep 17 00:00:00 2001 From: mbichara Date: Wed, 8 Oct 2025 15:07:43 -0300 Subject: [PATCH 004/143] #2641: Performing the chat msgs parsing in the task. --- .../config/conf/AISummarizationConfig.txt | 5 +- .../scripts/tasks/AISummarizationTask.py | 186 +++++++++++++++++- 2 files changed, 180 insertions(+), 11 deletions(-) diff --git a/iped-app/resources/config/conf/AISummarizationConfig.txt b/iped-app/resources/config/conf/AISummarizationConfig.txt index 968e9cfa28..de272f5f1a 100644 --- a/iped-app/resources/config/conf/AISummarizationConfig.txt +++ b/iped-app/resources/config/conf/AISummarizationConfig.txt @@ -10,4 +10,7 @@ remoteServiceAddress = 127.0.0.1:11111 enableWhatsAppSummarization = false # Enables automatic summarization of UFED supported chats using AI models. -enableUFEDChatSummarization = false \ No newline at end of file +enableUFEDChatSummarization = false + +# Minimum item content length to perform summarization in characters +minimumContentLength = 1000 \ No newline at end of file diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index 9d0c9a68cc..a67f9ef128 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -2,31 +2,34 @@ #need to install requests lib: .\target\release\iped-4.3.0-snapshot\python\python.exe .\target\release\iped-4.3.0-snapshot\python\get-pip.py requests #also numpy for some reason: .\target\release\iped-4.3.0-snapshot\python\python.exe .\target\release\iped-4.3.0-snapshot\python\get-pip.py "numpy<2.0" import json - - +from bs4 import BeautifulSoup, NavigableString, Tag +import re +from datetime import datetime # configuration properties enableProp = 'enableAISummarization' enableWhatsAppSummarizationProp = 'enableWhatsAppSummarization' # This is for IPED internal Parser enableUFEDChatSummarizationProp = 'enableUFEDChatSummarization' # This is for UFED Chat Parser - x-ufed-chat-preview -configFile = 'AISummarizationConfig.txt' +minimumContentLengthProp = 'minimumContentLength' # Minimum item content length to perform summarization in characters remoteServiceAddressProp = 'remoteServiceAddress' +configFile = 'AISummarizationConfig.txt' -def create_summaries_request(chat_content: str, base_url: str = "127.0.0.1:1111"): + +def create_summaries_request(msgs: list[dict] , base_url: str = "127.0.0.1:1111"): """ - Sends a POST request to the /api/create_summaries endpoint with chat content. + Sends a POST request to the /api/create_summaries_from_msgs endpoint with chat msgs. Args: - chat_content (str): The chat content to be summarized. + msgs (list[dict]): The chat msgs to be summarized, with content, direction, sender name etc. base_url (str): The base URL, defaults to "http://localhost:1111" for local development. Returns: dict: The JSON response from the API, or an error message. """ - url = f"http://{base_url}/api/create_summaries" + url = f"http://{base_url}/api/create_summaries_from_msgs" headers = {"Content-Type": "application/json"} - payload = {"chat_content": chat_content} + payload = {"msgs": msgs} try: response = requests.post(url, headers=headers, data=json.dumps(payload)) @@ -51,6 +54,151 @@ def create_summaries_request(chat_content: str, base_url: str = "127.0.0.1:1111" return {"error": f"JSON decode error: {json_err} - {response.text}"} +def _clean_timestamp(raw: str) -> str: + """ + Normaliza carimbos como “2023-01-08 19:38:20-0300” + para ISO 8601 (“2023-01-08T19:38:20-03:00”). + Se não conseguir entender, devolve o texto original. + """ + _TS_CLEAN = re.compile(r"\s+") + + if "Editada em" in raw: + raw = raw.split("Editada em")[0].strip() + txt = _TS_CLEAN.sub(" ", raw).strip() + + try: + # 2023-01-08 19:38:20-0300 + dt = datetime.strptime(txt, "%Y-%m-%d %H:%M:%S%z") + return dt.isoformat() + except ValueError: + pass + try: + # 2023-01-08 19:38:20-0300 + dt = datetime.strptime(txt, "%Y-%m-%d %H:%M:%S %z") + return dt.isoformat() + except ValueError: + return txt + +def _extract_text_nodes(tag: Tag) -> str: + """ + Concatena somente os nós-texto “soltos” dentro de *tag*. + Ignora
, , etc. + """ + + + parts = [t.strip() for t in tag.contents + if isinstance(t, NavigableString) and t.strip()] + return " ".join(parts) + +def getMessagesFromChatHTML(html_text: str) -> list[dict]: + + html_text = html_text.replace('
', '') + soup = BeautifulSoup(html_text, "html.parser") + msgs: list[dict] = [] + + len_mgs_content = 0 + + for block in soup.select("div.linha"): + msg_div = block.find("div", class_=["incoming", "outgoing"]) + + if msg_div is None: # linha de sistema / vazia + continue + + if msg_div.find("div", class_=["systemmessage"]): + continue + + msg_id = block.get('id') + + direction = ("received" + if "incoming" in msg_div["class"] + else "sent") + + forwarded = False + if msg_div.find("span", class_=["fwd"]): + forwarded = True + + name = msg_div.find("span").get_text(" ", strip=True) + #print(msg_div.prettify()) + timestamp_span = msg_div.find("span", class_="time") + if not timestamp_span: + print("No timestamp found") + timestamp = "N/A" + else: + timestamp_raw = timestamp_span.get_text(" ", strip=True) + timestamp = _clean_timestamp(timestamp_raw) + + # -------------------------------------------------------------------# + # 1) transcrição de áudio (fica em ) + # -------------------------------------------------------------------# + content = "" + kind = "" + i_tag = msg_div.find("i") + if i_tag and msg_div.find("div", class_=["audioImg"]): + content = i_tag.get_text(" ", strip=True) + kind = "audio transcription" + + + # -------------------------------------------------------------------# + # 2) anexo (áudio / vídeo / outro) + # -------------------------------------------------------------------# + if not content: + #kind = "other" + + # áudio ➜ ícone
+ if msg_div.find("div", class_="audioImg"): + kind = "audio" + content = f" " + if msg_div.find("div", class_="imageImg"): + kind = "image" + content = f" " + if msg_div.find("div", class_="videoImg"): + kind = "video" + content = f" " + # vídeo ou imagem ➜ thumbnail + else: + thumb = msg_div.find("img", class_="thumb") + if thumb and thumb.get("title"): + title = thumb["title"].lower() + if "video" in title: + kind = "video" + content = f" " + elif "image" in title: + kind = "image" + content = f" " + + #a_tag = msg_div.find("a", href=True) + #if a_tag: + # content = f" " + + # -------------------------------------------------------------------# + # 3) texto “puro” + # -------------------------------------------------------------------# + + if not content: + content = _extract_text_nodes(msg_div) + kind = "text" + + # ainda vazio? provavelmente só thumbs ou attachments sem link → pula + if not content: + continue + + len_mgs_content = len_mgs_content + len(content) + + msgs.append( + { + "id":msg_id, + "direction": direction, + "name": name, + "timestamp": timestamp, + "content": content, + "kind": kind, + "forwarded": forwarded + } + ) + + return msgs, len_mgs_content + + # The main class name must be equal to the script file name without .py extension @@ -61,6 +209,8 @@ class AISummarizationTask: remoteServiceAddress = None enableWhatsAppSummarization = False enableUFEDChatSummarization = False + minimumContentLength = None + def isEnabled(self): return AISummarizationTask.enabled @@ -97,6 +247,7 @@ def init(self, configuration): AISummarizationTask.enableWhatsAppSummarization = extraProps.getProperty(enableWhatsAppSummarizationProp) AISummarizationTask.enableUFEDChatSummarization = extraProps.getProperty(enableUFEDChatSummarizationProp) + AISummarizationTask.minimumContentLength = int(extraProps.getProperty(minimumContentLengthProp)) return @@ -118,8 +269,23 @@ def processChat(self, item): chatHtml = valid_bytes.decode('utf-8', errors='replace') inputStream.close() - - result = create_summaries_request(chatHtml, AISummarizationTask.remoteServiceAddress) + + msgs, len_mgs_content = getMessagesFromChatHTML(chatHtml) + + #print('------------------------------------------------------------------------------------------') + #print(msgs) + #print('------------------------------------------------------------------------------------------') + + #print(f"Num of messages: {len(msgs)} Size of contents: {len_mgs_content}") + + if(len_mgs_content < AISummarizationTask.minimumContentLength): + #skip + #print(f"Small chat - less than {AISummarizationTask.minimumContentLength} characters") + return + + result = create_summaries_request(msgs, AISummarizationTask.remoteServiceAddress) + #result = create_summaries_request(chatHtml, AISummarizationTask.remoteServiceAddress) + err = result.get('error') if err: print(f"[AISummarizationTask]: {item.getName()} - {err}") From 8cceea0a77545694e7101cb12ffba78aa26495db Mon Sep 17 00:00:00 2001 From: mbichara Date: Wed, 29 Oct 2025 10:34:34 -0300 Subject: [PATCH 005/143] #2641: Improvements in server communication and chat parsing --- .../scripts/tasks/AISummarizationTask.py | 322 ++++++++++++------ 1 file changed, 209 insertions(+), 113 deletions(-) diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index a67f9ef128..d8f732b62f 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -1,10 +1,11 @@ -import requests +import requests, time #need to install requests lib: .\target\release\iped-4.3.0-snapshot\python\python.exe .\target\release\iped-4.3.0-snapshot\python\get-pip.py requests #also numpy for some reason: .\target\release\iped-4.3.0-snapshot\python\python.exe .\target\release\iped-4.3.0-snapshot\python\get-pip.py "numpy<2.0" import json from bs4 import BeautifulSoup, NavigableString, Tag import re from datetime import datetime +from typing import List, Any, Dict, Tuple, Optional # configuration properties enableProp = 'enableAISummarization' @@ -14,71 +15,192 @@ remoteServiceAddressProp = 'remoteServiceAddress' configFile = 'AISummarizationConfig.txt' +#-------------------------------------------------------- +# Helper functions for the remote service error handling +#------------------------------------------------------- + +def _normalize(resp: requests.Response) -> Dict[str, Any]: + """Standardize into {ok, code, http_status, data?, message?, request_id?}.""" + status = resp.status_code + try: + body = resp.json() + except Exception: + body = None + + # Pass-through if server already uses the standard contract + if isinstance(body, dict) and "ok" in body and "http_status" in body: + return body + + if resp.ok: + return { + "ok": True, + "code": "OK", + "http_status": status, + "data": (body if isinstance(body, dict) else {"raw": body}), + } + + # Ensure message is never None for logs + msg = None + if isinstance(body, dict): + # Common places where a message may live + msg = ( + body.get("message") + or (body.get("data") or {}).get("message") + or (body.get("error") or {}).get("message") + or (body.get("data") or {}).get("detail") + ) + if not msg: + msg = resp.text or f"HTTP {status}" + + return {"ok": False, "code": "HTTP_ERROR", "http_status": status, "message": msg} -def create_summaries_request(msgs: list[dict] , base_url: str = "127.0.0.1:1111"): +def _fmt_error(res: Dict[str, Any]) -> str: """ - Sends a POST request to the /api/create_summaries_from_msgs endpoint with chat msgs. + Build a human-friendly one-liner from a normalized error dict. + Never returns 'None' for the message; includes request_id when provided. + """ + code = (res.get("code") or "UNKNOWN").upper() + http_status = res.get("http_status") + request_id = res.get("request_id") or (res.get("data") or {}).get("request_id") + + # pick the most informative message available and trim it a bit + message = ( + res.get("message") + or (res.get("data") or {}).get("message") + or (res.get("error") or {}).get("message") + or (res.get("data") or {}).get("detail") + or (f"HTTP {http_status}" if http_status else "No message available") + ) + msg = str(message).strip() + if len(msg) > 500: + msg = msg[:497] + "..." + + parts = [code] + if http_status is not None: + parts.append(f"({http_status})") + parts.append(f': "{msg}"') + if request_id: + parts.append(f"[request_id={request_id}]") + return " ".join(parts) + - Args: - msgs (list[dict]): The chat msgs to be summarized, with content, direction, sender name etc. - base_url (str): The base URL, defaults to "http://localhost:1111" for local development. +#-------------------------------------------------------- +# Remote service communication +#-------------------------------------------------------- - Returns: - dict: The JSON response from the API, or an error message. +def create_summaries_request( + msgs: list[dict], + base_url: str = "127.0.0.1:1111", + *, + BUSY_SLEEP: float = 1.0, # fixed sleep for BUSY retries + NONBUSY_SLEEP: float = 1.0, # fixed sleep for non-BUSY retries + MAX_ATTEMPTS_NONBUSY: int = 10, # max retries for non-BUSY errors +) -> Dict[str, Any]: + """ + - BUSY (code == 'BUSY'): retry forever, sleeping BUSY_SLEEP each time. + - Other errors: retry up to MAX_ATTEMPTS_NONBUSY with NONBUSY_SLEEP between attempts. + + NOTE: Logging is improved; logic is unchanged. """ url = f"http://{base_url}/api/create_summaries_from_msgs" - headers = {"Content-Type": "application/json"} - payload = {"msgs": msgs} + attempts_other = 0 + + while True: + try: + resp = requests.post(url, json={"msgs": msgs}) + res = _normalize(resp) + except requests.exceptions.Timeout: + res = { + "ok": False, + "code": "TIMEOUT", + "http_status": 408, + "message": "Request timed out.", + } + except requests.exceptions.ConnectionError as e: + res = { + "ok": False, + "code": "CONNECTION_ERROR", + "http_status": 503, + "message": str(e), + } + except requests.exceptions.RequestException as e: + res = { + "ok": False, + "code": "CLIENT_ERROR", + "http_status": 500, + "message": str(e), + } - try: - response = requests.post(url, headers=headers, data=json.dumps(payload)) - response.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx) - return response.json() - except requests.exceptions.HTTPError as http_err: - #print(f"HTTP error occurred: {http_err}") - #print(f"Response content: {response.text}") - return {"error": f"HTTP error: {http_err}-{response.text}"} - except requests.exceptions.ConnectionError as conn_err: - #print(f"Connection error occurred: {conn_err}") - return {"error": f"Connection error: {conn_err}"} - except requests.exceptions.Timeout as timeout_err: - #print(f"Timeout error occurred: {timeout_err}") - return {"error": f"Timeout error: {timeout_err}"} - except requests.exceptions.RequestException as req_err: - #print(f"An unexpected error occurred: {req_err}") - return {"error": f"An unexpected error: {req_err}"} - except json.JSONDecodeError as json_err: - #print(f"JSON decode error: {json_err}") - #print(f"Raw response: {response.text}") - return {"error": f"JSON decode error: {json_err} - {response.text}"} + if res.get("ok"): + return res + + code = (res.get("code") or "").upper() + + # Only BUSY loops forever (unchanged) + if code == "BUSY": + logger.info(f"[AISummarizationTask]: BUSY - retrying in {max(0.1, BUSY_SLEEP)}s.") + time.sleep(max(0.1, BUSY_SLEEP)) + continue + + # Non-BUSY: bounded retries (unchanged) + attempts_other += 1 + if attempts_other >= MAX_ATTEMPTS_NONBUSY: + # Final error log with nice formatting + logger.error(f"[AISummarizationTask]: Error - {_fmt_error(res)}") + return res + + # Intermediate warning with nice formatting + logger.warn(f"[AISummarizationTask]: Warning - {_fmt_error(res)}") + time.sleep(max(0.1, NONBUSY_SLEEP)) + + +#-------------------------------------------------------- +# Helper functions for the chat parsing +#-------------------------------------------------------- def _clean_timestamp(raw: str) -> str: """ - Normaliza carimbos como “2023-01-08 19:38:20-0300” - para ISO 8601 (“2023-01-08T19:38:20-03:00”). + Normaliza carimbos como "2023-01-08 19:38:20-0300" + para ISO 8601 ("2023-01-08T19:38:20-03:00"). Se não conseguir entender, devolve o texto original. """ - _TS_CLEAN = re.compile(r"\s+") - - if "Editada em" in raw: - raw = raw.split("Editada em")[0].strip() - txt = _TS_CLEAN.sub(" ", raw).strip() + # First extract just the timestamp part + #timestamp_pattern = r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} ?[+\-]\d{2}:\d{2}' + timestamp_pattern = r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: ?(?:[+\-]\d{2}:?\d{2}|Z))' + match = re.search(timestamp_pattern, raw) + if match: + txt = match.group() + else: + logger.warn(f'[AISummarizationTask]: Warning - No timestamp found in {raw}') + return raw + + # Now clean the extracted timestamp try: - # 2023-01-08 19:38:20-0300 + # "2023-01-08 19:38:20Z" dt = datetime.strptime(txt, "%Y-%m-%d %H:%M:%S%z") return dt.isoformat() except ValueError: pass try: - # 2023-01-08 19:38:20-0300 + # "2023-01-08 19:38:20 -03:00" dt = datetime.strptime(txt, "%Y-%m-%d %H:%M:%S %z") return dt.isoformat() except ValueError: + pass + try: + # "2023-01-08 19:38:20-0300" + # normalize "-0300" → "-03:00" + txt_norm = re.sub(r'([+\-]\d{2})(\d{2})$', r'\1:\2', txt) + dt = datetime.strptime(txt_norm, "%Y-%m-%d %H:%M:%S%z") + return dt.isoformat() + except ValueError: + logger.warn(f'[AISummarizationTask]: Could not parse timestamp {txt}') return txt + def _extract_text_nodes(tag: Tag) -> str: """ Concatena somente os nós-texto “soltos” dentro de *tag*. @@ -90,7 +212,7 @@ def _extract_text_nodes(tag: Tag) -> str: if isinstance(t, NavigableString) and t.strip()] return " ".join(parts) -def getMessagesFromChatHTML(html_text: str) -> list[dict]: +def getMessagesFromChatHTML(html_text: str) -> Tuple[List[Dict], int]: html_text = html_text.replace('
', '') soup = BeautifulSoup(html_text, "html.parser") @@ -101,7 +223,7 @@ def getMessagesFromChatHTML(html_text: str) -> list[dict]: for block in soup.select("div.linha"): msg_div = block.find("div", class_=["incoming", "outgoing"]) - if msg_div is None: # linha de sistema / vazia + if msg_div is None: # linha de sistema / vazia continue if msg_div.find("div", class_=["systemmessage"]): @@ -121,11 +243,11 @@ def getMessagesFromChatHTML(html_text: str) -> list[dict]: #print(msg_div.prettify()) timestamp_span = msg_div.find("span", class_="time") if not timestamp_span: - print("No timestamp found") - timestamp = "N/A" + logger.warn(f"[AISummarizationTask]: Warning - No span timestamp in {msg_div.prettify()}") + timestamp = None else: timestamp_raw = timestamp_span.get_text(" ", strip=True) - timestamp = _clean_timestamp(timestamp_raw) + timestamp = _clean_timestamp(timestamp_raw) if timestamp_raw else None # -------------------------------------------------------------------# # 1) transcrição de áudio (fica em ) @@ -187,12 +309,12 @@ def getMessagesFromChatHTML(html_text: str) -> list[dict]: msgs.append( { "id":msg_id, + "content": content, + "timestamp": timestamp, "direction": direction, "name": name, - "timestamp": timestamp, - "content": content, - "kind": kind, - "forwarded": forwarded + "forwarded": forwarded, + "kind": kind } ) @@ -205,23 +327,18 @@ def getMessagesFromChatHTML(html_text: str) -> list[dict]: # One instance of this class is created by each processing thread and each thread calls the implemented methods of its own object. class AISummarizationTask: - enabled = False - remoteServiceAddress = None - enableWhatsAppSummarization = False - enableUFEDChatSummarization = False - minimumContentLength = None - - - def isEnabled(self): - return AISummarizationTask.enabled - def __init__(self): + self.enabled = False + self.remoteServiceAddress = None + self.enableWhatsAppSummarization = False + self.enableUFEDChatSummarization = False + self.minimumContentLength = 0 return # Returns if this task is enabled or not. This could access options read by init() method. def isEnabled(self): - return AISummarizationTask.enabled + return self.enabled # Returns an optional list of configurable objects that can load/save parameters from/to config files. def getConfigurables(self): @@ -232,71 +349,50 @@ def getConfigurables(self): # It is executed when application starts by each processing thread on its own class instance. def init(self, configuration): taskConfig = configuration.getTaskConfigurable(configFile) - AISummarizationTask.enabled = taskConfig.isEnabled() - - if not AISummarizationTask.enabled: + self.enabled = taskConfig.isEnabled() + if not self.enabled: return - extraProps = taskConfig.getConfiguration() - - AISummarizationTask.remoteServiceAddress = extraProps.getProperty(remoteServiceAddressProp) - - if not AISummarizationTask.remoteServiceAddress: - print('[AISummarizationTask]: Error: task enabled but remoteServiceAddress not set on config file.') + self.remoteServiceAddress = extraProps.getProperty(remoteServiceAddressProp) + if not self.remoteServiceAddress: + logger.error('[AISummarizationTask]: Error: Task enabled but remoteServiceAddress not set on config file.') + self.enabled = False return - - AISummarizationTask.enableWhatsAppSummarization = extraProps.getProperty(enableWhatsAppSummarizationProp) - AISummarizationTask.enableUFEDChatSummarization = extraProps.getProperty(enableUFEDChatSummarizationProp) - AISummarizationTask.minimumContentLength = int(extraProps.getProperty(minimumContentLengthProp)) + self.enableWhatsAppSummarization = bool(extraProps.getProperty(enableWhatsAppSummarizationProp)) + self.enableUFEDChatSummarization = bool(extraProps.getProperty(enableUFEDChatSummarizationProp)) + self.minimumContentLength = int(extraProps.getProperty(minimumContentLengthProp) or 0) return def processChat(self, item): - - from iped.properties import ExtraProperties - - chunk_summaries = item.getExtraAttribute(ExtraProperties.SUMMARIES) - if chunk_summaries is not None: + if item.getExtraAttribute(ExtraProperties.SUMMARIES) is not None: return - - inputStream = item.getBufferedInputStream() - - raw_bytes = inputStream.readAllBytes() - # Ensure bytes are in valid range - valid_bytes = bytes(b & 0xFF for b in raw_bytes) - chatHtml = valid_bytes.decode('utf-8', errors='replace') - - inputStream.close() - - msgs, len_mgs_content = getMessagesFromChatHTML(chatHtml) - #print('------------------------------------------------------------------------------------------') - #print(msgs) - #print('------------------------------------------------------------------------------------------') - - #print(f"Num of messages: {len(msgs)} Size of contents: {len_mgs_content}") - - if(len_mgs_content < AISummarizationTask.minimumContentLength): - #skip - #print(f"Small chat - less than {AISummarizationTask.minimumContentLength} characters") + inputStream = item.getBufferedInputStream() + try: + raw_bytes = inputStream.readAllBytes() + finally: + inputStream.close() + + chatHtml = bytes(b & 0xFF for b in raw_bytes).decode('utf-8', errors='replace') + msgs, total_len = getMessagesFromChatHTML(chatHtml) + if len(msgs) == 0 or total_len < self.minimumContentLength: return - result = create_summaries_request(msgs, AISummarizationTask.remoteServiceAddress) - #result = create_summaries_request(chatHtml, AISummarizationTask.remoteServiceAddress) + res = create_summaries_request(msgs, self.remoteServiceAddress) + if not res["ok"]: + logger.error(f"[AISummarizationTask]: Error {item.getName()} - {res['code']} ({res['http_status']}): {res.get('message')}") + # Exit - server connection problem + raise Exception(f"[AISummarizationTask]: Error {item.getName()} - {res['code']} ({res['http_status']}): {res.get('message')}") + #return - err = result.get('error') - if err: - print(f"[AISummarizationTask]: {item.getName()} - {err}") - return - - #print(result['summaries']) - summaries = result['summaries'] + summaries = res["data"]["summaries"] - #For testing, just append some strings to the summaries - #summaries = [] - #summaries.append('adas asda gfdfg', 'sdfs sdf sdfsda') + if len(summaries) == 0: + logger.error(f"[AISummarizationTask]: Error - No summaries returned for {item.getName()}, this should not happen as we send only messages with content") + return item.setExtraAttribute(ExtraProperties.SUMMARIES, summaries) @@ -306,15 +402,15 @@ def processChat(self, item): # Process an Item object. This method is executed on all case items. # It can access any method of Item class and store results as a new extra attribute. def process(self, item): - if not AISummarizationTask.enabled: + if not self.enabled: return # WhatsApp chats processed by internal IPED parser - if AISummarizationTask.enableWhatsAppSummarization and "whatsapp-chat" in item.getMediaType().toString(): + if self.enableWhatsAppSummarization and "whatsapp-chat" in item.getMediaType().toString(): self.processChat(item) return # Process UFED Chats - if AISummarizationTask.enableUFEDChatSummarization and "x-ufed-chat-preview" in item.getMediaType().toString(): + if self.enableUFEDChatSummarization and "x-ufed-chat-preview" in item.getMediaType().toString(): self.processChat(item) return From 3a1bc52c2792153ca9236357d384d7635262316c Mon Sep 17 00:00:00 2001 From: mbichara Date: Tue, 9 Dec 2025 10:31:23 -0300 Subject: [PATCH 006/143] #2641: Add chat analysis (questions) functionality. --- .../scripts/tasks/AISummarizationTask.py | 229 +++++++++++++++++- 1 file changed, 216 insertions(+), 13 deletions(-) diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index d8f732b62f..9efb1923e1 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -9,12 +9,46 @@ # configuration properties enableProp = 'enableAISummarization' -enableWhatsAppSummarizationProp = 'enableWhatsAppSummarization' # This is for IPED internal Parser +enableWhatsAppSummarizationProp = 'enableWhatsAppSummarization' # This is for IPED internal WhatsApp Parser enableUFEDChatSummarizationProp = 'enableUFEDChatSummarization' # This is for UFED Chat Parser - x-ufed-chat-preview -minimumContentLengthProp = 'minimumContentLength' # Minimum item content length to perform summarization in characters +minimumContentLengthProp = 'minimumContentLength' # Minimum item content length remoteServiceAddressProp = 'remoteServiceAddress' configFile = 'AISummarizationConfig.txt' +# NEW: chat analysis / questions config +enableChatAnalysisProp = 'enableChatAnalysis' +questionsProp = 'questions' +questionAttributesProp = 'questionAttributes' + +# bookmarks config +analysisBookmarksCreated = False + + +def _parse_list_prop(raw: Optional[str]) -> List[str]: + """ + Parse a config property that may be a JSON list like + ["q1", "q2"] or a comma/semicolon-separated string. + """ + if not raw: + return [] + raw = raw.strip() + if not raw: + return [] + + # Try JSON list first + try: + val = json.loads(raw) + if isinstance(val, list): + return [str(v) for v in val] + except Exception: + pass + + # Fallback: comma or semicolon separated + sep = ';' if ';' in raw else ',' + return [p.strip() for p in raw.split(sep) if p.strip()] + + + #-------------------------------------------------------- # Helper functions for the remote service error handling #------------------------------------------------------- @@ -93,9 +127,10 @@ def create_summaries_request( msgs: list[dict], base_url: str = "127.0.0.1:1111", *, - BUSY_SLEEP: float = 1.0, # fixed sleep for BUSY retries - NONBUSY_SLEEP: float = 1.0, # fixed sleep for non-BUSY retries - MAX_ATTEMPTS_NONBUSY: int = 10, # max retries for non-BUSY errors + questions: Optional[List[str]] = None, # NEW + BUSY_SLEEP: float = 1.0, + NONBUSY_SLEEP: float = 1.0, + MAX_ATTEMPTS_NONBUSY: int = 10, ) -> Dict[str, Any]: """ - BUSY (code == 'BUSY'): retry forever, sleeping BUSY_SLEEP each time. @@ -108,7 +143,10 @@ def create_summaries_request( while True: try: - resp = requests.post(url, json={"msgs": msgs}) + payload: Dict[str, Any] = {"msgs": msgs} + if questions: # NEW + payload["questions"] = questions + resp = requests.post(url, json=payload) res = _normalize(resp) except requests.exceptions.Timeout: res = { @@ -254,8 +292,9 @@ def getMessagesFromChatHTML(html_text: str) -> Tuple[List[Dict], int]: # -------------------------------------------------------------------# content = "" kind = "" + i_tag = msg_div.find("i") - if i_tag and msg_div.find("div", class_=["audioImg"]): + if i_tag and msg_div.find("div", class_=["audioImg"]) and "Recovered message" not in i_tag.get_text(" ", strip=True): content = i_tag.get_text(" ", strip=True) kind = "audio transcription" @@ -334,6 +373,12 @@ def __init__(self): self.enableWhatsAppSummarization = False self.enableUFEDChatSummarization = False self.minimumContentLength = 0 + + # NEW: chat analysis / questions + self.enableChatAnalysis = False + self.questions: List[str] = [] + self.questionAttributes: List[str] = [] + return # Returns if this task is enabled or not. This could access options read by init() method. @@ -361,6 +406,21 @@ def init(self, configuration): self.enableWhatsAppSummarization = bool(extraProps.getProperty(enableWhatsAppSummarizationProp)) self.enableUFEDChatSummarization = bool(extraProps.getProperty(enableUFEDChatSummarizationProp)) self.minimumContentLength = int(extraProps.getProperty(minimumContentLengthProp) or 0) + + # NEW: chat analysis-related options + self.enableChatAnalysis = bool(extraProps.getProperty(enableChatAnalysisProp)) + if self.enableChatAnalysis: + self.questions = _parse_list_prop(extraProps.getProperty(questionsProp)) + self.questionAttributes = _parse_list_prop(extraProps.getProperty(questionAttributesProp)) + + + if self.enableChatAnalysis and (len(self.questions) == 0 or len(self.questionAttributes) == 0): + logger.error("[AISummarizationTask]: Warning - 'questions' and 'questionAttributes' are not set.") + raise Exception(f"[AISummarizationTask]: Warning - 'questions' and 'questionAttributes' are not set.") + + if self.enableChatAnalysis and len(self.questions) != len(self.questionAttributes): + logger.error("[AISummarizationTask]: Warning - 'questions' and 'questionAttributes' have different sizes.") + raise Exception(f"[AISummarizationTask]: Warning - 'questions' and 'questionAttributes' have different sizes.") return @@ -381,20 +441,101 @@ def processChat(self, item): if len(msgs) == 0 or total_len < self.minimumContentLength: return - res = create_summaries_request(msgs, self.remoteServiceAddress) + questions = None + if self.enableChatAnalysis and self.questions: + questions = self.questions + + res = create_summaries_request( + msgs, + self.remoteServiceAddress, + questions=questions + ) + if not res["ok"]: logger.error(f"[AISummarizationTask]: Error {item.getName()} - {res['code']} ({res['http_status']}): {res.get('message')}") # Exit - server connection problem raise Exception(f"[AISummarizationTask]: Error {item.getName()} - {res['code']} ({res['http_status']}): {res.get('message')}") #return - summaries = res["data"]["summaries"] + + + #summaries = res["data"]["summaries"] + + #if len(summaries) == 0: + # logger.error(f"[AISummarizationTask]: Error - No summaries returned for {item.getName()}, this should not happen as we send only messages with content") + # return - if len(summaries) == 0: - logger.error(f"[AISummarizationTask]: Error - No summaries returned for {item.getName()}, this should not happen as we send only messages with content") + #item.setExtraAttribute(ExtraProperties.SUMMARIES, summaries) + + data = res.get("data") + if not isinstance(data, list): + logger.error( + f"[AISummarizationTask]: Error - Unexpected response format for " + f"{item.getName()}, expected list of results." + ) + return + + # ---------------------------------------------------------- + # 1) Extract summaries + # 2) Build per-attribute arrays of answers (one entry per chunk) + # ---------------------------------------------------------- + chunk_summaries: List[str] = [] + + per_attr_answers: Dict[str, List[str]] = {} + if self.enableChatAnalysis and self.questions and self.questionAttributes: + # initialize one list per configured attribute + per_attr_answers = {attr: [] for attr in self.questionAttributes} + + for idx, entry in enumerate(data): + if not isinstance(entry, dict): + logger.warn( + f"[AISummarizationTask]: Warning - Result entry {idx} for " + f"{item.getName()} is not a dict; skipping." + ) + continue + + # --- summaries --- + summary = entry.get("summary") + if isinstance(summary, str) and summary.strip(): + chunk_summaries.append(summary) + + # --- answers per question / attribute --- + if per_attr_answers: + answers = entry.get("answers") + if not isinstance(answers, list): + # If this chunk has no answers list, append "0" for each attribute to keep lengths aligned + for attr in self.questionAttributes: + per_attr_answers[attr].append("0") + continue + + # For each question index, append its answer to the corresponding attribute array + for q_idx, attr_name in enumerate(self.questionAttributes): + if q_idx < len(answers): + per_attr_answers[attr_name].append(str(answers[q_idx])) + else: + # No answer for this question in this chunk → default "0" + per_attr_answers[attr_name].append("0") + + if len(chunk_summaries) == 0: + logger.error( + f"[AISummarizationTask]: Error - No summaries returned for {item.getName()}, " + "this should not happen as we send only messages with content" + ) return - item.setExtraAttribute(ExtraProperties.SUMMARIES, summaries) + # Store all chunk summaries (same attribute as before) + item.setExtraAttribute(ExtraProperties.SUMMARIES, chunk_summaries) + + # Store per-question arrays in their respective attributes + for attr_name, values in per_attr_answers.items(): + full_attr_name = f"ai:analysis:{attr_name}" + try: + item.setExtraAttribute(full_attr_name, values) + except Exception as e: + logger.warn( + f"[AISummarizationTask]: Warning - Could not set attribute " + f"{full_attr_name} for {item.getName()}: {e}" + ) @@ -420,4 +561,66 @@ def process(self, item): # Called when task processing is finished. Can be used to cleanup resources. # Objects "ipedCase" and "searcher" are provided, so case can be queried for items and bookmarks can be created. def finish(self): - return \ No newline at end of file + """ + Creates bookmarks for all ai:analysis:* attributes after processing. + One pair of bookmarks per questionAttribute: + - Todos os analisados (score >= 0) + - Alta relevância (score >= 750) + """ + from iped.properties import ExtraProperties + + global analysisBookmarksCreated + if analysisBookmarksCreated: + return + + # If analysis is disabled or there are no configured attributes, do nothing + if not getattr(self, "enableChatAnalysis", False) or not getattr(self, "questionAttributes", None): + return + + try: + # Loop over each configured question attribute + for attr_name in self.questionAttributes: + # ai:analysis: → ai\\:analysis\\: in Lucene query + field = f"ai\\:analysis\\:{attr_name}" + + # All analyzed (score >= 0) + query_all = f"{field}: [0 TO *]" + + # High relevance (score >= 750) + query_high = f"{field}: [750 TO *]" + + # ---- Bookmark: all analyzed for this attribute ---- + searcher.setQuery(query_all) + ids = searcher.search().getIds() + + if len(ids) > 0: + bookmarkId = ipedCase.getBookmarks().newBookmark( + f"IA: {attr_name} - todos analisados" + ) + ipedCase.getBookmarks().setBookmarkComment( + bookmarkId, + f"{len(ids)} chats possuem valores em {field} (score >= 0)." + ) + ipedCase.getBookmarks().addBookmark(ids, bookmarkId) + + # ---- Bookmark: high relevance for this attribute ---- + searcher.setQuery(query_high) + ids = searcher.search().getIds() + + if len(ids) > 0: + bookmarkId = ipedCase.getBookmarks().newBookmark( + f"IA: {attr_name} - alta relevância" + ) + ipedCase.getBookmarks().setBookmarkComment( + bookmarkId, + f"{len(ids)} chats possuem alta relevância em {field} (score >= 750)." + ) + ipedCase.getBookmarks().addBookmark(ids, bookmarkId) + + # Persist all bookmark changes once at the end + ipedCase.getBookmarks().saveState(True) + + analysisBookmarksCreated = True + + except Exception as e: + logger.error(f"[AISummarizationTask]: Error creating analysis bookmarks in finish(): {e}") From b40d53f13574923ea6f361b8da44218842327d57 Mon Sep 17 00:00:00 2001 From: mbichara Date: Tue, 9 Dec 2025 15:36:55 -0300 Subject: [PATCH 007/143] #2641: Fix boolean conversion and add chunk_ids in metadata --- .../scripts/tasks/AISummarizationTask.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index 9efb1923e1..cdacc80a03 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -1,6 +1,9 @@ import requests, time #need to install requests lib: .\target\release\iped-4.3.0-snapshot\python\python.exe .\target\release\iped-4.3.0-snapshot\python\get-pip.py requests #also numpy for some reason: .\target\release\iped-4.3.0-snapshot\python\python.exe .\target\release\iped-4.3.0-snapshot\python\get-pip.py "numpy<2.0" +# git add iped-app/resources/scripts/tasks/AISummarizationTask.py +# git commit -m "#2641: " +# git push origin add-aisummarizationtask --force-with-lease import json from bs4 import BeautifulSoup, NavigableString, Tag import re @@ -403,12 +406,14 @@ def init(self, configuration): logger.error('[AISummarizationTask]: Error: Task enabled but remoteServiceAddress not set on config file.') self.enabled = False return - self.enableWhatsAppSummarization = bool(extraProps.getProperty(enableWhatsAppSummarizationProp)) - self.enableUFEDChatSummarization = bool(extraProps.getProperty(enableUFEDChatSummarizationProp)) + + self.enableWhatsAppSummarization = (extraProps.getProperty(enableWhatsAppSummarizationProp)) == "true" + self.enableUFEDChatSummarization = (extraProps.getProperty(enableUFEDChatSummarizationProp)) == "true" + self.minimumContentLength = int(extraProps.getProperty(minimumContentLengthProp) or 0) # NEW: chat analysis-related options - self.enableChatAnalysis = bool(extraProps.getProperty(enableChatAnalysisProp)) + self.enableChatAnalysis = (extraProps.getProperty(enableChatAnalysisProp)) == "true" if self.enableChatAnalysis: self.questions = _parse_list_prop(extraProps.getProperty(questionsProp)) self.questionAttributes = _parse_list_prop(extraProps.getProperty(questionAttributesProp)) @@ -480,6 +485,7 @@ def processChat(self, item): # 2) Build per-attribute arrays of answers (one entry per chunk) # ---------------------------------------------------------- chunk_summaries: List[str] = [] + chunk_ids: List[str] = [] per_attr_answers: Dict[str, List[str]] = {} if self.enableChatAnalysis and self.questions and self.questionAttributes: @@ -499,6 +505,11 @@ def processChat(self, item): if isinstance(summary, str) and summary.strip(): chunk_summaries.append(summary) + # --- chunk ids --- + chunk_id = entry.get("chunk_id") + if isinstance(chunk_id, str) and chunk_id.strip(): + chunk_ids.append(chunk_id) + # --- answers per question / attribute --- if per_attr_answers: answers = entry.get("answers") @@ -526,6 +537,9 @@ def processChat(self, item): # Store all chunk summaries (same attribute as before) item.setExtraAttribute(ExtraProperties.SUMMARIES, chunk_summaries) + # Store chunk ids + item.setExtraAttribute("chunk_ids", chunk_ids) + # Store per-question arrays in their respective attributes for attr_name, values in per_attr_answers.items(): full_attr_name = f"ai:analysis:{attr_name}" From 70c7dbaa30180d7eeb3b835578124e844207da54 Mon Sep 17 00:00:00 2001 From: mbichara Date: Wed, 10 Dec 2025 16:47:58 -0300 Subject: [PATCH 008/143] #2641: After rebase additions --- .../main/java/iped/properties/ExtraProperties.java | 2 ++ iped-app/resources/config/IPEDConfig.txt | 5 +++++ .../resources/config/conf/AISummarizationConfig.txt | 12 ++++++++++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/iped-api/src/main/java/iped/properties/ExtraProperties.java b/iped-api/src/main/java/iped/properties/ExtraProperties.java index ccf4fdcf83..270a49e297 100644 --- a/iped-api/src/main/java/iped/properties/ExtraProperties.java +++ b/iped-api/src/main/java/iped/properties/ExtraProperties.java @@ -188,6 +188,8 @@ public class ExtraProperties { public static final String FACE_AGE_LABELS = "faceAge:labels"; + public static final String SUMMARIES = "ai:summaries"; + /** * Property to be set if the evidence is a animated image (i.e. contain multiple * frames). Only set if the number of frames is greater than one. diff --git a/iped-app/resources/config/IPEDConfig.txt b/iped-app/resources/config/IPEDConfig.txt index 91f21c8717..252d274254 100644 --- a/iped-app/resources/config/IPEDConfig.txt +++ b/iped-app/resources/config/IPEDConfig.txt @@ -131,6 +131,11 @@ enableImageSimilarity = false # If enabled, you can search for faces from the analysis interface, check the options menu. enableFaceRecognition = false +# Enables AI Summarization task with LLM models using a remote server. +# When enabled, summaries will be generated for supported files and displayed in the analysis interface. +# Configuration options can be found in conf/AISummarizationConfig.txt. +enableAISummarization = false + # Enables age estimation feature. # You may install python and some dependencies, see https://github.com/sepinf-inc/IPED/wiki/User-Manual#AgeEstimation # Advanced configuration options can be found in conf/AgeEstimationConfig.txt. diff --git a/iped-app/resources/config/conf/AISummarizationConfig.txt b/iped-app/resources/config/conf/AISummarizationConfig.txt index de272f5f1a..06865412f9 100644 --- a/iped-app/resources/config/conf/AISummarizationConfig.txt +++ b/iped-app/resources/config/conf/AISummarizationConfig.txt @@ -3,7 +3,7 @@ ############################################################################## # IP:PORT of the service/central node used by the AISummarizationTask implementation. -remoteServiceAddress = 127.0.0.1:11111 +remoteServiceAddress = 127.0.0.1:8000 # Enables automatic summarization of WhatsApp chats using AI models. # When set to true, summaries will be generated for WhatsApp conversations parsed by IPED's internal parser. @@ -13,4 +13,12 @@ enableWhatsAppSummarization = false enableUFEDChatSummarization = false # Minimum item content length to perform summarization in characters -minimumContentLength = 1000 \ No newline at end of file +minimumContentLength = 1000 + +# Options for chat analysis with AI LLM. +# When 'enableChatAnalysis' is true, custom questions in 'questions' are sent to the AI summarizer, +# and the returned scores (0-999) will be available as attributes listed in 'questionAttributes'. +enableChatAnalysis = false + +questions = ["Question1?", "Question2?"] +questionAttributes = ["question1Score", "question2Score"] \ No newline at end of file From 1c47602e504d2213740e99c03a3d9a3bb98e2ed2 Mon Sep 17 00:00:00 2001 From: mbichara Date: Fri, 12 Dec 2025 15:58:13 -0300 Subject: [PATCH 009/143] #2641: Small changes --- .../scripts/tasks/AISummarizationTask.py | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index cdacc80a03..d51256bac4 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -18,13 +18,19 @@ remoteServiceAddressProp = 'remoteServiceAddress' configFile = 'AISummarizationConfig.txt' +# Remote service communication parameters +BUSY_SLEEP_TIME = 10.0 +NONBUSY_SLEEP_TIME = 1.0, +MAX_ATTEMPTS_NONBUSY_RETRIES = 10, + # NEW: chat analysis / questions config enableChatAnalysisProp = 'enableChatAnalysis' questionsProp = 'questions' questionAttributesProp = 'questionAttributes' -# bookmarks config -analysisBookmarksCreated = False +# bookmarks config - Just for testing purposes, set to False to test bookmarks creation +# I believe it is better to show AI analysis results in the AI panel. +analysisBookmarksCreated = True def _parse_list_prop(raw: Optional[str]) -> List[str]: @@ -131,9 +137,9 @@ def create_summaries_request( base_url: str = "127.0.0.1:1111", *, questions: Optional[List[str]] = None, # NEW - BUSY_SLEEP: float = 1.0, - NONBUSY_SLEEP: float = 1.0, - MAX_ATTEMPTS_NONBUSY: int = 10, + BUSY_SLEEP: float = BUSY_SLEEP_TIME, + NONBUSY_SLEEP: float = NONBUSY_SLEEP_TIME, + MAX_ATTEMPTS_NONBUSY: int = MAX_ATTEMPTS_NONBUSY_RETRIES, ) -> Dict[str, Any]: """ - BUSY (code == 'BUSY'): retry forever, sleeping BUSY_SLEEP each time. @@ -312,10 +318,10 @@ def getMessagesFromChatHTML(html_text: str) -> Tuple[List[Dict], int]: if msg_div.find("div", class_="audioImg"): kind = "audio" content = f" " - if msg_div.find("div", class_="imageImg"): + elif msg_div.find("div", class_="imageImg"): kind = "image" content = f" " - if msg_div.find("div", class_="videoImg"): + elif msg_div.find("div", class_="videoImg"): kind = "video" content = f" " # vídeo ou imagem ➜ thumbnail @@ -407,13 +413,13 @@ def init(self, configuration): self.enabled = False return - self.enableWhatsAppSummarization = (extraProps.getProperty(enableWhatsAppSummarizationProp)) == "true" - self.enableUFEDChatSummarization = (extraProps.getProperty(enableUFEDChatSummarizationProp)) == "true" + self.enableWhatsAppSummarization = (extraProps.getProperty(enableWhatsAppSummarizationProp) or "").lower() == "true" + self.enableUFEDChatSummarization = (extraProps.getProperty(enableUFEDChatSummarizationProp) or "").lower() == "true" self.minimumContentLength = int(extraProps.getProperty(minimumContentLengthProp) or 0) # NEW: chat analysis-related options - self.enableChatAnalysis = (extraProps.getProperty(enableChatAnalysisProp)) == "true" + self.enableChatAnalysis = (extraProps.getProperty(enableChatAnalysisProp) or "").lower() == "true" if self.enableChatAnalysis: self.questions = _parse_list_prop(extraProps.getProperty(questionsProp)) self.questionAttributes = _parse_list_prop(extraProps.getProperty(questionAttributesProp)) @@ -460,7 +466,6 @@ def processChat(self, item): logger.error(f"[AISummarizationTask]: Error {item.getName()} - {res['code']} ({res['http_status']}): {res.get('message')}") # Exit - server connection problem raise Exception(f"[AISummarizationTask]: Error {item.getName()} - {res['code']} ({res['http_status']}): {res.get('message')}") - #return @@ -522,7 +527,7 @@ def processChat(self, item): # For each question index, append its answer to the corresponding attribute array for q_idx, attr_name in enumerate(self.questionAttributes): if q_idx < len(answers): - per_attr_answers[attr_name].append(str(answers[q_idx])) + per_attr_answers[attr_name].append(int(answers[q_idx])) else: # No answer for this question in this chunk → default "0" per_attr_answers[attr_name].append("0") @@ -538,7 +543,7 @@ def processChat(self, item): item.setExtraAttribute(ExtraProperties.SUMMARIES, chunk_summaries) # Store chunk ids - item.setExtraAttribute("chunk_ids", chunk_ids) + item.setExtraAttribute("ai:chunk_ids", chunk_ids) # Store per-question arrays in their respective attributes for attr_name, values in per_attr_answers.items(): From 89fcad6ed60a4d3b31b78c442f16d54a1dde21a1 Mon Sep 17 00:00:00 2001 From: mbichara Date: Mon, 22 Dec 2025 14:46:02 -0300 Subject: [PATCH 010/143] #2641: Fix sleep constants --- iped-app/resources/scripts/tasks/AISummarizationTask.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index d51256bac4..47722a3191 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -20,8 +20,8 @@ # Remote service communication parameters BUSY_SLEEP_TIME = 10.0 -NONBUSY_SLEEP_TIME = 1.0, -MAX_ATTEMPTS_NONBUSY_RETRIES = 10, +NONBUSY_SLEEP_TIME = 1.0 +MAX_ATTEMPTS_NONBUSY_RETRIES = 10 # NEW: chat analysis / questions config enableChatAnalysisProp = 'enableChatAnalysis' From ee9447a47346389b216a4617a594582841396972 Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Fri, 16 Jan 2026 21:49:14 -0300 Subject: [PATCH 011/143] '#2641: Code formatting. --- .../main/java/iped/viewers/SummaryViewer.java | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java index 90d49bbbb9..3603b40e12 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -18,12 +18,27 @@ public class SummaryViewer extends HtmlViewer { public SummaryViewer() { - super(); // delegates to HtmlViewer constructor + // delegates to HtmlViewer constructor + super(); } - @Override public String getName() { return "Summary"; } // or wire into i18n later - @Override public boolean isSupportedType(String contentType) { return true; } // we gate by data presence - @Override public int getHitsSupported() { return -1; } // no Prev/Next hit buttons for this tab + @Override + public String getName() { + // or wire into i18n later + return "Summary"; + } + + @Override + public boolean isSupportedType(String contentType) { + // we gate by data presence + return true; + } + + @Override + public int getHitsSupported() { + // no Prev/Next hit buttons for this tab + return -1; + } /** Quick presence check so controller can decide tab visibility. */ public boolean hasSummaries(IStreamSource content) { @@ -47,7 +62,6 @@ public void loadFile(final IStreamSource content, final Set terms) { loadFile(content, null, terms); } - @Override public void loadFile(final IStreamSource content, String contentType, final Set terms) { // Reuse HtmlViewer's highlighter: set highlightTerms and load HTML directly in the WebEngine. @@ -64,7 +78,6 @@ public void loadFile(final IStreamSource content, String contentType, final Set< //List chunks = new ArrayList<>(); //ArrayList chunks = new ArrayList<>(); ArrayList chunks = new ArrayList<>(); - Object value = item.getExtraAttributeMap().get(ExtraProperties.SUMMARIES); if (value instanceof Collection) { @@ -91,8 +104,6 @@ public void loadFile(final IStreamSource content, String contentType, final Set< } } - - // Simple, readable HTML; HtmlViewer will highlight search terms after load. StringBuilder html = new StringBuilder(); html.append("") From 9f06124f6cd1b4d2d0077d0e48a5cb2ff4e3c5d0 Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Sun, 18 Jan 2026 22:35:52 -0300 Subject: [PATCH 012/143] '#2641: Remove unused/commented code. --- .../src/main/java/iped/viewers/SummaryViewer.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java index 3603b40e12..cc1ae5a1e6 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -75,8 +75,6 @@ public void loadFile(final IStreamSource content, String contentType, final Set< } IItemReader item = (IItemReader) content; - //List chunks = new ArrayList<>(); - //ArrayList chunks = new ArrayList<>(); ArrayList chunks = new ArrayList<>(); Object value = item.getExtraAttributeMap().get(ExtraProperties.SUMMARIES); @@ -117,10 +115,8 @@ public void loadFile(final IStreamSource content, String contentType, final Set< html.append("
AI-generated summaries. Check all information").append("
"); for (int i = 0; i < chunks.size(); i++) { - //String c = SimpleHTMLEncoder.htmlEncode(chunks.get(i)); String c = SimpleHTMLEncoder.htmlEncode(chunks.get(i)).replaceAll("\n","
"); html.append("
") - //.append("
Chunk ").append(i + 1).append("
") .append("
").append(c).append("
") .append("
"); } From 38beb85af7a1dbba1d6cd533a41f4f0c3f4dc6e8 Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 09:48:14 -0300 Subject: [PATCH 013/143] '#2641: Change the property name from "ai:summaries" to "ai:summary". --- .../main/java/iped/properties/ExtraProperties.java | 2 +- .../resources/scripts/tasks/AISummarizationTask.py | 6 +++--- .../main/java/iped/app/ui/ViewerController.java | 2 +- .../src/main/java/iped/viewers/SummaryViewer.java | 14 +++++++------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/iped-api/src/main/java/iped/properties/ExtraProperties.java b/iped-api/src/main/java/iped/properties/ExtraProperties.java index 270a49e297..09261e622c 100644 --- a/iped-api/src/main/java/iped/properties/ExtraProperties.java +++ b/iped-api/src/main/java/iped/properties/ExtraProperties.java @@ -188,7 +188,7 @@ public class ExtraProperties { public static final String FACE_AGE_LABELS = "faceAge:labels"; - public static final String SUMMARIES = "ai:summaries"; + public static final String SUMMARY = "ai:summary"; /** * Property to be set if the evidence is a animated image (i.e. contain multiple diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index 47722a3191..3c6794f2c5 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -438,7 +438,7 @@ def init(self, configuration): def processChat(self, item): from iped.properties import ExtraProperties - if item.getExtraAttribute(ExtraProperties.SUMMARIES) is not None: + if item.getExtraAttribute(ExtraProperties.SUMMARY) is not None: return inputStream = item.getBufferedInputStream() @@ -475,7 +475,7 @@ def processChat(self, item): # logger.error(f"[AISummarizationTask]: Error - No summaries returned for {item.getName()}, this should not happen as we send only messages with content") # return - #item.setExtraAttribute(ExtraProperties.SUMMARIES, summaries) + #item.setExtraAttribute(ExtraProperties.SUMMARY, summaries) data = res.get("data") if not isinstance(data, list): @@ -540,7 +540,7 @@ def processChat(self, item): return # Store all chunk summaries (same attribute as before) - item.setExtraAttribute(ExtraProperties.SUMMARIES, chunk_summaries) + item.setExtraAttribute(ExtraProperties.SUMMARY, chunk_summaries) # Store chunk ids item.setExtraAttribute("ai:chunk_ids", chunk_ids) diff --git a/iped-app/src/main/java/iped/app/ui/ViewerController.java b/iped-app/src/main/java/iped/app/ui/ViewerController.java index 4ec9fa0011..1c536e0620 100644 --- a/iped-app/src/main/java/iped/app/ui/ViewerController.java +++ b/iped-app/src/main/java/iped/app/ui/ViewerController.java @@ -317,7 +317,7 @@ public void updateViewer(AbstractViewer viewer, boolean clean, boolean forceLoad DefaultSingleCDockable dock = dockPerViewer.get(viewer); if (viewer instanceof SummaryViewer) { - boolean has = file != null && ((SummaryViewer) viewer).hasSummaries(file); + boolean has = file != null && ((SummaryViewer) viewer).hasSummary(file); CControl cc = App.get().dockingControl; boolean registered = dock != null && isRegistered(dock); diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java index cc1ae5a1e6..fd1f4cabfc 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -12,7 +12,7 @@ import iped.utils.UiUtil; /** - * Shows ExtraProperties.SUMMARIES (array of strings) for the current item. + * Shows ExtraProperties.SUMMARY (array of strings) for the current item. * Extends HtmlViewer to reuse hit highlighting & WebView plumbing. */ public class SummaryViewer extends HtmlViewer { @@ -41,19 +41,19 @@ public int getHitsSupported() { } /** Quick presence check so controller can decide tab visibility. */ - public boolean hasSummaries(IStreamSource content) { + public boolean hasSummary(IStreamSource content) { if (!(content instanceof IItemReader)) return false; IItemReader item = (IItemReader) content; // Check extra attributes (preferred) - Object v = item.getExtraAttributeMap().get(ExtraProperties.SUMMARIES); + Object v = item.getExtraAttributeMap().get(ExtraProperties.SUMMARY); if (v instanceof Collection && !((Collection) v).isEmpty()) return true; if (v instanceof Object[] && ((Object[]) v).length > 0) return true; if (v instanceof String && !((String) v).isEmpty()) return true; if (v != null) return true; // some non-null single value // Fallback: metadata bag - String[] vals = item.getMetadata().getValues(ExtraProperties.SUMMARIES); + String[] vals = item.getMetadata().getValues(ExtraProperties.SUMMARY); return vals != null && vals.length > 0; } @@ -69,7 +69,7 @@ public void loadFile(final IStreamSource content, String contentType, final Set< this.tmpFile = null; // ensure the "location endsWith(tmpFile)" early-return never triggers javafx.application.Platform.runLater(() -> { - if (!(content instanceof IItemReader) || !hasSummaries(content)) { + if (!(content instanceof IItemReader) || !hasSummary(content)) { webEngine.loadContent(UiUtil.getUIEmptyHtml()); return; } @@ -77,7 +77,7 @@ public void loadFile(final IStreamSource content, String contentType, final Set< IItemReader item = (IItemReader) content; ArrayList chunks = new ArrayList<>(); - Object value = item.getExtraAttributeMap().get(ExtraProperties.SUMMARIES); + Object value = item.getExtraAttributeMap().get(ExtraProperties.SUMMARY); if (value instanceof Collection) { for (Object v : (Collection) value) { if (v != null) chunks.add(v.toString()); @@ -94,7 +94,7 @@ public void loadFile(final IStreamSource content, String contentType, final Set< // Fallback to metadata if we still have nothing if (chunks.isEmpty()) { - String[] vals = item.getMetadata().getValues(ExtraProperties.SUMMARIES); + String[] vals = item.getMetadata().getValues(ExtraProperties.SUMMARY); if (vals != null) { for (String s : vals) { if (s != null) chunks.add(s); From b6c74822b9beb10c0381b7da8658797d20c472a8 Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 09:50:39 -0300 Subject: [PATCH 014/143] '#2641: Rename "ai:chunk_ids" to "ai:chunkIds". --- iped-api/src/main/java/iped/properties/ExtraProperties.java | 1 + iped-app/resources/scripts/tasks/AISummarizationTask.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/iped-api/src/main/java/iped/properties/ExtraProperties.java b/iped-api/src/main/java/iped/properties/ExtraProperties.java index 09261e622c..e13a401a0f 100644 --- a/iped-api/src/main/java/iped/properties/ExtraProperties.java +++ b/iped-api/src/main/java/iped/properties/ExtraProperties.java @@ -189,6 +189,7 @@ public class ExtraProperties { public static final String FACE_AGE_LABELS = "faceAge:labels"; public static final String SUMMARY = "ai:summary"; + public static final String CHUNK_IDS = "ai:chunkIds"; /** * Property to be set if the evidence is a animated image (i.e. contain multiple diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index 3c6794f2c5..38bd37590e 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -543,7 +543,7 @@ def processChat(self, item): item.setExtraAttribute(ExtraProperties.SUMMARY, chunk_summaries) # Store chunk ids - item.setExtraAttribute("ai:chunk_ids", chunk_ids) + item.setExtraAttribute(ExtraProperties.CHUNK_IDS, chunk_ids) # Store per-question arrays in their respective attributes for attr_name, values in per_attr_answers.items(): From e233dae95db2a9e2d8ed0aeaaacc5b0dab35aa08 Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 11:30:31 -0300 Subject: [PATCH 015/143] '#2641: Simplify hasSummary() checks and the access to extra attribute. --- .../src/main/java/iped/viewers/SummaryViewer.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java index fd1f4cabfc..0dd257cdac 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -46,11 +46,8 @@ public boolean hasSummary(IStreamSource content) { IItemReader item = (IItemReader) content; // Check extra attributes (preferred) - Object v = item.getExtraAttributeMap().get(ExtraProperties.SUMMARY); - if (v instanceof Collection && !((Collection) v).isEmpty()) return true; - if (v instanceof Object[] && ((Object[]) v).length > 0) return true; - if (v instanceof String && !((String) v).isEmpty()) return true; - if (v != null) return true; // some non-null single value + Object v = item.getExtraAttribute(ExtraProperties.SUMMARY); + if (v != null) return true; // Fallback: metadata bag String[] vals = item.getMetadata().getValues(ExtraProperties.SUMMARY); @@ -77,7 +74,7 @@ public void loadFile(final IStreamSource content, String contentType, final Set< IItemReader item = (IItemReader) content; ArrayList chunks = new ArrayList<>(); - Object value = item.getExtraAttributeMap().get(ExtraProperties.SUMMARY); + Object value = item.getExtraAttribute(ExtraProperties.SUMMARY); if (value instanceof Collection) { for (Object v : (Collection) value) { if (v != null) chunks.add(v.toString()); From ce69444b26aec5418d4f592ed3145945fa12a0ca Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 11:36:47 -0300 Subject: [PATCH 016/143] '#2641: Allow hit navigation (left/right arrows) in the Summary viewer. --- .../src/main/java/iped/viewers/SummaryViewer.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java index 0dd257cdac..20ce17f38f 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -36,8 +36,7 @@ public boolean isSupportedType(String contentType) { @Override public int getHitsSupported() { - // no Prev/Next hit buttons for this tab - return -1; + return 1; } /** Quick presence check so controller can decide tab visibility. */ From 4fb7468b6fe52838336b4b68ffb7ebd24609de06 Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 16:16:15 -0300 Subject: [PATCH 017/143] '#2641: Simplify "Summary" tab visibility management. --- .../java/iped/app/ui/ViewerController.java | 77 ++++++------------- 1 file changed, 22 insertions(+), 55 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ViewerController.java b/iped-app/src/main/java/iped/app/ui/ViewerController.java index 1c536e0620..79aba437cf 100644 --- a/iped-app/src/main/java/iped/app/ui/ViewerController.java +++ b/iped-app/src/main/java/iped/app/ui/ViewerController.java @@ -302,63 +302,11 @@ public boolean hasHits() { return highlightTerms != null && !highlightTerms.isEmpty(); } - // helper - private boolean isRegistered(DefaultSingleCDockable d) { - CControl cc = App.get().dockingControl; - for (int i = 0; i < cc.getCDockableCount(); i++) { - if (cc.getCDockable(i) == d) return true; - } - return false; - } - - public void updateViewer(AbstractViewer viewer, boolean clean, boolean forceLoad) { - - DefaultSingleCDockable dock = dockPerViewer.get(viewer); - - if (viewer instanceof SummaryViewer) { - boolean has = file != null && ((SummaryViewer) viewer).hasSummary(file); - CControl cc = App.get().dockingControl; - boolean registered = dock != null && isRegistered(dock); - - if (!has) { - if (registered) { - // ensure some other tab is selected, then remove Summary from the control - AbstractViewer keep = getBestViewer(viewType != null ? viewType : contentType); - DefaultSingleCDockable keepDock = dockPerViewer.get(keep); - if (keepDock != null && keepDock != dock) App.get().selectDockableTab(keepDock); - - SwingUtilities.invokeLater(() -> { - // remove instead of setVisible(false) to prevent the blank stub - cc.removeDockable(dock); - }); - } - if (clean && isInitialized()) viewer.loadFile(null); - return; // done with Summary - } else { - if (!registered) { - // add back and place it alongside the current best viewer - DefaultSingleCDockable keepDock = dockPerViewer.get(getBestViewer(viewType != null ? viewType : contentType)); - SwingUtilities.invokeLater(() -> { - cc.addDockable(dock); - if (keepDock != null) dock.setLocationsAside(keepDock); - dock.setVisible(true); - // keep selection on preview/metadata/etc. - if (keepDock != null) App.get().selectDockableTab(keepDock); - }); - } else if (!dock.isVisible()) { - dock.setVisible(true); - } - // do NOT return; let the generic branch load Summary only when its panel is showing - } - } - // --- end Summary block --- - - if (viewer.getPanel().isShowing() || (viewer.equals(textViewer) && hasHits()) || forceLoad) { if (isInitialized()) loadInViewer(viewer); - //DefaultSingleCDockable dock = dockPerViewer.get(viewer); + DefaultSingleCDockable dock = dockPerViewer.get(viewer); if (dock != null) { boolean hitsEnabled = viewFile != null && ((hasHits() && viewer.getHitsSupported() == 0) || (viewer.getHitsSupported() == 1)); @@ -434,7 +382,7 @@ private AbstractViewer getBestViewer(String contentType) { for (AbstractViewer viewer : viewers) { // Never make Summary the default tab - if (viewer instanceof iped.viewers.SummaryViewer) continue; + if (viewer instanceof SummaryViewer) continue; if (viewer.isSupportedType(contentType, true)) { if (viewer instanceof MetadataViewer) { @@ -475,5 +423,24 @@ public void notifyAppLoaded() { viewer.setEnableHighlightFacesButton(enableHighlightFacesButton); viewer.setEnableAgeEstimationCombo(enableAgeEstimationCombo); }); + + // Remove Summary viewer and dock if no summary was found. + boolean hasSummaryInIndex = App.get().appCase.getLeafReader().getFieldInfos() + .fieldInfo(ExtraProperties.SUMMARY) != null; + if (!hasSummaryInIndex) { + for (int i = 0; i < viewers.size(); i++) { + AbstractViewer viewer = viewers.get(i); + if (viewer instanceof SummaryViewer) { + DefaultSingleCDockable dock = dockPerViewer.get(viewer); + dockPerViewer.remove(viewer); + viewers.remove(i); + CControl cControl = dock.getControl(); + if (cControl != null) { + cControl.removeDockable(dock); + } + break; + } + } + } } -} \ No newline at end of file +} From 4f98ad5b5bca0aea904b27c65694086a08b16095 Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 19:27:54 -0300 Subject: [PATCH 018/143] '#2641: Show a message in the viewer, when there is no summary. --- .../src/main/java/iped/utils/UiUtil.java | 28 +++++++++++++++---- .../main/java/iped/viewers/SummaryViewer.java | 4 +-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/iped-utils/src/main/java/iped/utils/UiUtil.java b/iped-utils/src/main/java/iped/utils/UiUtil.java index d63ca2e6e0..253638e86c 100644 --- a/iped-utils/src/main/java/iped/utils/UiUtil.java +++ b/iped-utils/src/main/java/iped/utils/UiUtil.java @@ -29,13 +29,31 @@ public static Color mix(Color c1, Color c2, double weight) { } public static String getUIEmptyHtml() { + return getUIEmptyHtml(null); + } + + public static String getUIEmptyHtml(String msg) { StringBuilder sb = new StringBuilder(); - sb.append(""); + if (msg != null && !msg.isBlank()) { + sb.append("
").append(msg).append("

"); } - sb.append("\">"); //$NON-NLS-1$ + sb.append(""); return sb.toString(); } diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java index 20ce17f38f..ce77711e19 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -66,7 +66,7 @@ public void loadFile(final IStreamSource content, String contentType, final Set< javafx.application.Platform.runLater(() -> { if (!(content instanceof IItemReader) || !hasSummary(content)) { - webEngine.loadContent(UiUtil.getUIEmptyHtml()); + webEngine.loadContent(UiUtil.getUIEmptyHtml("[No summary available]")); return; } @@ -104,7 +104,7 @@ public void loadFile(final IStreamSource content, String contentType, final Set< .append("") .append(""); From 881581f61cc3cdfb9cdea3b42bd31ade3d5c78f5 Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 19:43:27 -0300 Subject: [PATCH 019/143] '#2641: Handle dark theme, using Viewer.background/foreground. --- .../main/java/iped/viewers/SummaryViewer.java | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java index ce77711e19..3930429354 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -1,10 +1,13 @@ package iped.viewers; +import java.awt.Color; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Set; +import javax.swing.UIManager; + import iped.data.IItemReader; import iped.io.IStreamSource; import iped.properties.ExtraProperties; @@ -98,17 +101,30 @@ public void loadFile(final IStreamSource content, String contentType, final Set< } } + // Use theme colors + Color background = UIManager.getColor("Viewer.background"); + if (background == null) { + background = Color.white; + } + Color foreground = UIManager.getColor("Viewer.foreground"); + if (foreground == null) { + foreground = Color.black; + } + // Simple, readable HTML; HtmlViewer will highlight search terms after load. StringBuilder html = new StringBuilder(); html.append("") .append("") .append(""); - html.append("
AI-generated summaries. Check all information").append("
"); + html.append("
AI-generated summary. Check all information.").append("
"); for (int i = 0; i < chunks.size(); i++) { String c = SimpleHTMLEncoder.htmlEncode(chunks.get(i)).replaceAll("\n","
"); @@ -118,8 +134,6 @@ public void loadFile(final IStreamSource content, String contentType, final Set< } html.append(""); - // Keep IPED HTML style for consistent colors (HtmlViewer uses this). - webEngine.setUserStyleSheetLocation(UiUtil.getUIHtmlStyle()); webEngine.setJavaScriptEnabled(false); // not needed here webEngine.loadContent(html.toString()); }); From b8e359b34d46f498ee01476bd96bb25f82468dc1 Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 19:53:12 -0300 Subject: [PATCH 020/143] '#2641: Add missing updateUI() to HitsTable. --- .../java/iped/viewers/components/HitsTable.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/components/HitsTable.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/components/HitsTable.java index 41a8c10bce..5958fa3f2a 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/components/HitsTable.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/components/HitsTable.java @@ -18,10 +18,12 @@ */ package iped.viewers.components; +import java.awt.Color; import java.awt.Dimension; import javax.swing.JTable; import javax.swing.ListSelectionModel; +import javax.swing.UIManager; import javax.swing.table.AbstractTableModel; public class HitsTable extends JTable { @@ -42,4 +44,16 @@ public void changeSelection(int rowIndex, int columnIndex, boolean toggle, boole super.changeSelection(rowIndex, 0, toggle, extend); } + @Override + public void updateUI() { + Color background = UIManager.getColor("Viewer.background"); + if (background == null) + background = Color.WHITE; + setBackground(background); + Color foreground = UIManager.getColor("Viewer.foreground"); + if (foreground == null) + foreground = Color.BLACK; + setForeground(foreground); + super.updateUI(); + } } From 984924cc40c21bc13fdbcf579b5285b3d9a85f4d Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 20:45:31 -0300 Subject: [PATCH 021/143] '#2641: Support Telegram and Threema. Use internal/external properties. --- .../config/conf/AISummarizationConfig.txt | 11 ++++--- .../scripts/tasks/AISummarizationTask.py | 29 +++++++++---------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/iped-app/resources/config/conf/AISummarizationConfig.txt b/iped-app/resources/config/conf/AISummarizationConfig.txt index 06865412f9..12849b24b9 100644 --- a/iped-app/resources/config/conf/AISummarizationConfig.txt +++ b/iped-app/resources/config/conf/AISummarizationConfig.txt @@ -5,12 +5,11 @@ # IP:PORT of the service/central node used by the AISummarizationTask implementation. remoteServiceAddress = 127.0.0.1:8000 -# Enables automatic summarization of WhatsApp chats using AI models. -# When set to true, summaries will be generated for WhatsApp conversations parsed by IPED's internal parser. -enableWhatsAppSummarization = false +# Enables automatic summarization of internally parsed chats (WhatsApp, Telegram etc.) using AI models. +enableInternalChatSummarization = true -# Enables automatic summarization of UFED supported chats using AI models. -enableUFEDChatSummarization = false +# Enables automatic summarization of externally parsed chats (UFED) using AI models. +enableExternalChatSummarization = true # Minimum item content length to perform summarization in characters minimumContentLength = 1000 @@ -21,4 +20,4 @@ minimumContentLength = 1000 enableChatAnalysis = false questions = ["Question1?", "Question2?"] -questionAttributes = ["question1Score", "question2Score"] \ No newline at end of file +questionAttributes = ["question1Score", "question2Score"] diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index 38bd37590e..677debe1ba 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -12,8 +12,8 @@ # configuration properties enableProp = 'enableAISummarization' -enableWhatsAppSummarizationProp = 'enableWhatsAppSummarization' # This is for IPED internal WhatsApp Parser -enableUFEDChatSummarizationProp = 'enableUFEDChatSummarization' # This is for UFED Chat Parser - x-ufed-chat-preview +enableInternalSummarizationProp = 'enableInternalChatSummarization' # This is for IPED internal chats parsers (WhatsApp, Telegram etc.) +enableExternalSummarizationProp = 'enableExternalChatSummarization' # This is for external (UFED) chat parsers - x-ufed-chat-preview minimumContentLengthProp = 'minimumContentLength' # Minimum item content length remoteServiceAddressProp = 'remoteServiceAddress' configFile = 'AISummarizationConfig.txt' @@ -379,8 +379,8 @@ class AISummarizationTask: def __init__(self): self.enabled = False self.remoteServiceAddress = None - self.enableWhatsAppSummarization = False - self.enableUFEDChatSummarization = False + self.enableInternalSummarizationProp = False + self.enableExternalSummarizationProp = False self.minimumContentLength = 0 # NEW: chat analysis / questions @@ -413,8 +413,8 @@ def init(self, configuration): self.enabled = False return - self.enableWhatsAppSummarization = (extraProps.getProperty(enableWhatsAppSummarizationProp) or "").lower() == "true" - self.enableUFEDChatSummarization = (extraProps.getProperty(enableUFEDChatSummarizationProp) or "").lower() == "true" + self.enableInternalSummarization = (extraProps.getProperty(enableInternalSummarizationProp) or "").lower() == "true" + self.enableExternalSummarization = (extraProps.getProperty(enableExternalSummarizationProp) or "").lower() == "true" self.minimumContentLength = int(extraProps.getProperty(minimumContentLengthProp) or 0) @@ -467,8 +467,7 @@ def processChat(self, item): # Exit - server connection problem raise Exception(f"[AISummarizationTask]: Error {item.getName()} - {res['code']} ({res['http_status']}): {res.get('message')}") - - + #summaries = res["data"]["summaries"] #if len(summaries) == 0: @@ -555,8 +554,6 @@ def processChat(self, item): f"[AISummarizationTask]: Warning - Could not set attribute " f"{full_attr_name} for {item.getName()}: {e}" ) - - # Process an Item object. This method is executed on all case items. @@ -565,16 +562,16 @@ def process(self, item): if not self.enabled: return - # WhatsApp chats processed by internal IPED parser - if self.enableWhatsAppSummarization and "whatsapp-chat" in item.getMediaType().toString(): + # Process chats parsed by internal IPED parsers + mimes = {"application/x-whatsapp-chat", "application/x-telegram-chat", "application/x-threema-chat"} + if self.enableInternalSummarization and item.getMediaType().toString() in mimes: self.processChat(item) return - # Process UFED Chats - if self.enableUFEDChatSummarization and "x-ufed-chat-preview" in item.getMediaType().toString(): + + # Process external (UFED) chats + if self.enableExternalSummarization and "x-ufed-chat-preview" in item.getMediaType().toString(): self.processChat(item) return - - # Called when task processing is finished. Can be used to cleanup resources. From f6dce63b37ae51e84ce537e5707f1d236724013a Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 21:07:30 -0300 Subject: [PATCH 022/143] '#2641: Skip chats if "Communication:isEmpty" is "true". --- iped-app/resources/scripts/tasks/AISummarizationTask.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index 677debe1ba..e44b0c4ec3 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -441,6 +441,10 @@ def processChat(self, item): if item.getExtraAttribute(ExtraProperties.SUMMARY) is not None: return + # Skip empty chats + if (item.getMetadata().get(ExtraProperties.COMMUNICATION_PREFIX + "isEmpty") or "").lower() == "true": + return + inputStream = item.getBufferedInputStream() try: raw_bytes = inputStream.readAllBytes() From f77c23b7dd3a3f6e3fd397c137850897282c98db Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 22:18:34 -0300 Subject: [PATCH 023/143] Show "Summarized Chats" in the AI panel. --- .../resources/config/conf/AIFiltersConfig.json | 2 ++ .../localization/iped-ai-filters.properties | 2 ++ .../localization/iped-ai-filters_de_DE.properties | 2 ++ .../localization/iped-ai-filters_es_AR.properties | 2 ++ .../localization/iped-ai-filters_fr_FR.properties | 2 ++ .../localization/iped-ai-filters_it_IT.properties | 2 ++ .../localization/iped-ai-filters_pt_BR.properties | 2 ++ .../app/ui/filter/Summary.Summarized Chats.png | Bin 0 -> 420 bytes 8 files changed, 14 insertions(+) create mode 100644 iped-app/src/main/resources/iped/app/ui/filter/Summary.Summarized Chats.png diff --git a/iped-app/resources/config/conf/AIFiltersConfig.json b/iped-app/resources/config/conf/AIFiltersConfig.json index d06e0b1340..877870c369 100644 --- a/iped-app/resources/config/conf/AIFiltersConfig.json +++ b/iped-app/resources/config/conf/AIFiltersConfig.json @@ -47,6 +47,8 @@ {"name": "Low", "value": "[1 TO 99]"} ]}, + {"name": "Summarized Chats", "prefix": "Summary", "property": "ai\\:summary", "value": "*"}, + {"name": "Transcribed Audio", "prefix": "Transcript", "property": "audio\\:transcription", "value": "*"}, {"name": "UFED Media Classification", "prefix": "UfedClassifier", "property": "ufed\\:mediaClasses", "value": "*", "dynamic": "true"}, diff --git a/iped-app/resources/localization/iped-ai-filters.properties b/iped-app/resources/localization/iped-ai-filters.properties index c082dfaba0..6553cf5cec 100644 --- a/iped-app/resources/localization/iped-ai-filters.properties +++ b/iped-app/resources/localization/iped-ai-filters.properties @@ -38,6 +38,8 @@ OCR.High=High (more than 1000) OCR.Medium=Medium (100 to 1000) OCR.Low=Low (less than 100) +Summary.Summarized\ Chats=Summarized Chats + Transcript.Transcribed\ Audio=Transcribed Audio UfedClassifier.UFED\ Media\ Classification=UFED Media Classification diff --git a/iped-app/resources/localization/iped-ai-filters_de_DE.properties b/iped-app/resources/localization/iped-ai-filters_de_DE.properties index 93899a51ad..0135424d01 100644 --- a/iped-app/resources/localization/iped-ai-filters_de_DE.properties +++ b/iped-app/resources/localization/iped-ai-filters_de_DE.properties @@ -38,6 +38,8 @@ OCR.High=hoch (mehr als 1000) OCR.Medium=mittel (100 bis 1000) OCR.Low=niedrig (weniger als 100) +Summary.Summarized\ Chats=Summarized Chats[TBT] + Transcript.Transcribed\ Audio=verschriftetes Audio UfedClassifier.UFED\ Media\ Classification=UFED Medienklassifizierung diff --git a/iped-app/resources/localization/iped-ai-filters_es_AR.properties b/iped-app/resources/localization/iped-ai-filters_es_AR.properties index 4cf3088ae9..c7be8d0b62 100644 --- a/iped-app/resources/localization/iped-ai-filters_es_AR.properties +++ b/iped-app/resources/localization/iped-ai-filters_es_AR.properties @@ -38,6 +38,8 @@ OCR.High=Alt (más de 1000) OCR.Medium=Medio (entre 100 y 1000) OCR.Low=Bajo (mnenos de 100) +Summary.Summarized\ Chats=Summarized Chats[TBT] + Transcript.Transcribed\ Audio=Audio Transcrito UfedClassifier.UFED\ Media\ Classification=Clasificación de Medios UFED diff --git a/iped-app/resources/localization/iped-ai-filters_fr_FR.properties b/iped-app/resources/localization/iped-ai-filters_fr_FR.properties index 936974634f..97a329c498 100644 --- a/iped-app/resources/localization/iped-ai-filters_fr_FR.properties +++ b/iped-app/resources/localization/iped-ai-filters_fr_FR.properties @@ -38,6 +38,8 @@ OCR.High=Élevé (plus de 1000) OCR.Medium=Moyen (100 à 1000) OCR.Low=Bas (moins de 100) +Summary.Summarized\ Chats=Summarized Chats[TBT] + Transcript.Transcribed\ Audio=Transcription Audio UfedClassifier.UFED\ Media\ Classification=UFED Classification de Média diff --git a/iped-app/resources/localization/iped-ai-filters_it_IT.properties b/iped-app/resources/localization/iped-ai-filters_it_IT.properties index b5e52909c2..5f29f4991e 100644 --- a/iped-app/resources/localization/iped-ai-filters_it_IT.properties +++ b/iped-app/resources/localization/iped-ai-filters_it_IT.properties @@ -38,6 +38,8 @@ OCR.High=Alto (più di 1000) OCR.Medium=Medio (da 100 a 1000) OCR.Low=Basso (meno di 100) +Summary.Summarized\ Chats=Summarized Chats[TBT] + Transcript.Transcribed\ Audio=Audio Trascritto UfedClassifier.UFED\ Media\ Classification=Classificazione Media UFED diff --git a/iped-app/resources/localization/iped-ai-filters_pt_BR.properties b/iped-app/resources/localization/iped-ai-filters_pt_BR.properties index b78180c3c9..4d8ce83510 100644 --- a/iped-app/resources/localization/iped-ai-filters_pt_BR.properties +++ b/iped-app/resources/localization/iped-ai-filters_pt_BR.properties @@ -38,6 +38,8 @@ OCR.High=Alta (mais de 1000) OCR.Medium=Média (100 a 1000) OCR.Low=Baixa (menos que 100) +Summary.Summarized\ Chats=Chats Resumidos + Transcript.Transcribed\ Audio=Áudios Transcritos UfedClassifier.UFED\ Media\ Classification=UFED Classificação de Mídia diff --git a/iped-app/src/main/resources/iped/app/ui/filter/Summary.Summarized Chats.png b/iped-app/src/main/resources/iped/app/ui/filter/Summary.Summarized Chats.png new file mode 100644 index 0000000000000000000000000000000000000000..d9cb032c84daabc8026bd9aa7d3acdf508adebe4 GIT binary patch literal 420 zcmV;V0bBlwP)4HIaVK@LJar5z2x?qr9I34iv^OOG{zQ3gj2ANIJ z0U(960wA+Th68rIScNVG26S^k)Cmi8Auu4>0k>XV`oHDc5~A?me}CcnKn{pFZVKna z0Lczme|g^jgynVwanr60xE_$@FpLgJb^xj1*5^wo4nTJV$qv931-oyqK{6HsCfusR zupHeHBs&0C6htqvgsa*3bRmX?*l_SMga04DzbDNBgh1p8GYpIH;dytuNOJ(OQIK)g z3&S#W3<6Y(0uZj>kpfo-1L&55@SJ?>3`lkW zDNz8!vyQj^4_j!2WGw=a>;P(mQ3nhS2Ml<#oE{jf0|vZnHc)uL9RL7#-#nhqZ>T2# O0000 Date: Tue, 27 Jan 2026 22:33:50 -0300 Subject: [PATCH 024/143] '#2641: Localize the "Summary" tab title. --- .../localization/iped-viewer-messages.properties | 1 + .../localization/iped-viewer-messages_de_DE.properties | 1 + .../localization/iped-viewer-messages_es_AR.properties | 1 + .../localization/iped-viewer-messages_fr_FR.properties | 1 + .../localization/iped-viewer-messages_it_IT.properties | 1 + .../localization/iped-viewer-messages_pt_BR.properties | 1 + .../src/main/java/iped/viewers/SummaryViewer.java | 9 ++------- 7 files changed, 8 insertions(+), 7 deletions(-) diff --git a/iped-app/resources/localization/iped-viewer-messages.properties b/iped-app/resources/localization/iped-viewer-messages.properties index 1741cebdee..bc08b7a87c 100644 --- a/iped-app/resources/localization/iped-viewer-messages.properties +++ b/iped-app/resources/localization/iped-viewer-messages.properties @@ -129,3 +129,4 @@ ProgressDialog.Progress=Progress ProgressDialog.Searching=Searching... ReferenceViewer.FileNotFound=Referenced File Not Found:\ ReferenceViewer.NotSupported=File type not supported:\ +SummaryViewer.TabName=Summary diff --git a/iped-app/resources/localization/iped-viewer-messages_de_DE.properties b/iped-app/resources/localization/iped-viewer-messages_de_DE.properties index 8830ddb35e..c3b129dd3f 100644 --- a/iped-app/resources/localization/iped-viewer-messages_de_DE.properties +++ b/iped-app/resources/localization/iped-viewer-messages_de_DE.properties @@ -129,3 +129,4 @@ ProgressDialog.Progress=Fortschritt ProgressDialog.Searching=Suche... ReferenceViewer.FileNotFound=refenzierte Datei nicht gefunden:\ ReferenceViewer.NotSupported=nicht unterstützter Dateityp:\ +SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_es_AR.properties b/iped-app/resources/localization/iped-viewer-messages_es_AR.properties index ddd743b2ff..22bfdf2e5e 100644 --- a/iped-app/resources/localization/iped-viewer-messages_es_AR.properties +++ b/iped-app/resources/localization/iped-viewer-messages_es_AR.properties @@ -129,3 +129,4 @@ ProgressDialog.Progress=Progreso ProgressDialog.Searching=Buscando... ReferenceViewer.FileNotFound=Archivo referencia no encontrado:\ ReferenceViewer.NotSupported=Tipo de archivo no admitido:\ +SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties b/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties index d269a229d6..958eaa566d 100644 --- a/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties +++ b/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties @@ -129,3 +129,4 @@ ProgressDialog.Progress=En cours ProgressDialog.Searching=Recherche... ReferenceViewer.FileNotFound=Fichier de référence introuvable :\ ReferenceViewer.NotSupported=Type de fichier non supporté :\ +SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_it_IT.properties b/iped-app/resources/localization/iped-viewer-messages_it_IT.properties index 63ab19550b..f9e241af2a 100644 --- a/iped-app/resources/localization/iped-viewer-messages_it_IT.properties +++ b/iped-app/resources/localization/iped-viewer-messages_it_IT.properties @@ -129,3 +129,4 @@ ProgressDialog.Progress=Avanzamento ProgressDialog.Searching=Ricerca... ReferenceViewer.FileNotFound=File di riferimento non trovato:\ ReferenceViewer.NotSupported=Tipo di file non supportato:\ +SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties b/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties index 8bf498d1b5..46ae2fdbe9 100644 --- a/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties +++ b/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties @@ -129,3 +129,4 @@ ProgressDialog.Progress=Progresso ProgressDialog.Searching=Pesquisando... ReferenceViewer.FileNotFound=Arquivo Referenciado não encontrado:\ ReferenceViewer.NotSupported=Tipo de arquivo não suportado:\ +SummaryViewer.TabName=Resumo diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java index 3930429354..f4be96e4e1 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -13,6 +13,7 @@ import iped.properties.ExtraProperties; import iped.utils.SimpleHTMLEncoder; import iped.utils.UiUtil; +import iped.viewers.localization.Messages; /** * Shows ExtraProperties.SUMMARY (array of strings) for the current item. @@ -20,15 +21,9 @@ */ public class SummaryViewer extends HtmlViewer { - public SummaryViewer() { - // delegates to HtmlViewer constructor - super(); - } - @Override public String getName() { - // or wire into i18n later - return "Summary"; + return Messages.getString("SummaryViewer.TabName"); } @Override From 9fbb476210f23c27d69bcdf9e4d088a58b24232b Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 22:40:26 -0300 Subject: [PATCH 025/143] '#2641: Localize the summary title ("AI-generated summary. Check ..."). --- iped-app/resources/localization/iped-viewer-messages.properties | 1 + .../localization/iped-viewer-messages_de_DE.properties | 1 + .../localization/iped-viewer-messages_es_AR.properties | 1 + .../localization/iped-viewer-messages_fr_FR.properties | 1 + .../localization/iped-viewer-messages_it_IT.properties | 1 + .../localization/iped-viewer-messages_pt_BR.properties | 1 + .../src/main/java/iped/viewers/SummaryViewer.java | 2 +- 7 files changed, 7 insertions(+), 1 deletion(-) diff --git a/iped-app/resources/localization/iped-viewer-messages.properties b/iped-app/resources/localization/iped-viewer-messages.properties index bc08b7a87c..4f04528f2b 100644 --- a/iped-app/resources/localization/iped-viewer-messages.properties +++ b/iped-app/resources/localization/iped-viewer-messages.properties @@ -129,4 +129,5 @@ ProgressDialog.Progress=Progress ProgressDialog.Searching=Searching... ReferenceViewer.FileNotFound=Referenced File Not Found:\ ReferenceViewer.NotSupported=File type not supported:\ +SummaryViewer.Title=AI-generated summary. Check all information. SummaryViewer.TabName=Summary diff --git a/iped-app/resources/localization/iped-viewer-messages_de_DE.properties b/iped-app/resources/localization/iped-viewer-messages_de_DE.properties index c3b129dd3f..608cf16792 100644 --- a/iped-app/resources/localization/iped-viewer-messages_de_DE.properties +++ b/iped-app/resources/localization/iped-viewer-messages_de_DE.properties @@ -129,4 +129,5 @@ ProgressDialog.Progress=Fortschritt ProgressDialog.Searching=Suche... ReferenceViewer.FileNotFound=refenzierte Datei nicht gefunden:\ ReferenceViewer.NotSupported=nicht unterstützter Dateityp:\ +SummaryViewer.Title=AI-generated summary. Check all information. [TBT] SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_es_AR.properties b/iped-app/resources/localization/iped-viewer-messages_es_AR.properties index 22bfdf2e5e..f7c828bd6c 100644 --- a/iped-app/resources/localization/iped-viewer-messages_es_AR.properties +++ b/iped-app/resources/localization/iped-viewer-messages_es_AR.properties @@ -129,4 +129,5 @@ ProgressDialog.Progress=Progreso ProgressDialog.Searching=Buscando... ReferenceViewer.FileNotFound=Archivo referencia no encontrado:\ ReferenceViewer.NotSupported=Tipo de archivo no admitido:\ +SummaryViewer.Title=AI-generated summary. Check all information. [TBT] SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties b/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties index 958eaa566d..8db1f6c4dc 100644 --- a/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties +++ b/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties @@ -129,4 +129,5 @@ ProgressDialog.Progress=En cours ProgressDialog.Searching=Recherche... ReferenceViewer.FileNotFound=Fichier de référence introuvable :\ ReferenceViewer.NotSupported=Type de fichier non supporté :\ +SummaryViewer.Title=AI-generated summary. Check all information. [TBT] SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_it_IT.properties b/iped-app/resources/localization/iped-viewer-messages_it_IT.properties index f9e241af2a..40e0a2fefb 100644 --- a/iped-app/resources/localization/iped-viewer-messages_it_IT.properties +++ b/iped-app/resources/localization/iped-viewer-messages_it_IT.properties @@ -129,4 +129,5 @@ ProgressDialog.Progress=Avanzamento ProgressDialog.Searching=Ricerca... ReferenceViewer.FileNotFound=File di riferimento non trovato:\ ReferenceViewer.NotSupported=Tipo di file non supportato:\ +SummaryViewer.Title=AI-generated summary. Check all information. [TBT] SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties b/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties index 46ae2fdbe9..a94b0674e7 100644 --- a/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties +++ b/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties @@ -129,4 +129,5 @@ ProgressDialog.Progress=Progresso ProgressDialog.Searching=Pesquisando... ReferenceViewer.FileNotFound=Arquivo Referenciado não encontrado:\ ReferenceViewer.NotSupported=Tipo de arquivo não suportado:\ +SummaryViewer.Title=Resumo gerado por IA. Verifique as informações. SummaryViewer.TabName=Resumo diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java index f4be96e4e1..8aab76a4d9 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -119,7 +119,7 @@ public void loadFile(final IStreamSource content, String contentType, final Set< .append("") .append(""); - html.append("
AI-generated summary. Check all information.").append("
"); + html.append("
").append(Messages.getString("SummaryViewer.Title")).append("
"); for (int i = 0; i < chunks.size(); i++) { String c = SimpleHTMLEncoder.htmlEncode(chunks.get(i)).replaceAll("\n","
"); From dd80abff81a9d052d03f09b651de842e1d54ac41 Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Tue, 27 Jan 2026 22:44:08 -0300 Subject: [PATCH 026/143] '#2641: Localize "No Summary Available" viewer message. --- iped-app/resources/localization/iped-viewer-messages.properties | 1 + .../localization/iped-viewer-messages_de_DE.properties | 1 + .../localization/iped-viewer-messages_es_AR.properties | 1 + .../localization/iped-viewer-messages_fr_FR.properties | 1 + .../localization/iped-viewer-messages_it_IT.properties | 1 + .../localization/iped-viewer-messages_pt_BR.properties | 1 + .../src/main/java/iped/viewers/SummaryViewer.java | 2 +- 7 files changed, 7 insertions(+), 1 deletion(-) diff --git a/iped-app/resources/localization/iped-viewer-messages.properties b/iped-app/resources/localization/iped-viewer-messages.properties index 4f04528f2b..8717101bce 100644 --- a/iped-app/resources/localization/iped-viewer-messages.properties +++ b/iped-app/resources/localization/iped-viewer-messages.properties @@ -129,5 +129,6 @@ ProgressDialog.Progress=Progress ProgressDialog.Searching=Searching... ReferenceViewer.FileNotFound=Referenced File Not Found:\ ReferenceViewer.NotSupported=File type not supported:\ +SummaryViewer.NoSummary=No summary available SummaryViewer.Title=AI-generated summary. Check all information. SummaryViewer.TabName=Summary diff --git a/iped-app/resources/localization/iped-viewer-messages_de_DE.properties b/iped-app/resources/localization/iped-viewer-messages_de_DE.properties index 608cf16792..ff3e36d4a5 100644 --- a/iped-app/resources/localization/iped-viewer-messages_de_DE.properties +++ b/iped-app/resources/localization/iped-viewer-messages_de_DE.properties @@ -129,5 +129,6 @@ ProgressDialog.Progress=Fortschritt ProgressDialog.Searching=Suche... ReferenceViewer.FileNotFound=refenzierte Datei nicht gefunden:\ ReferenceViewer.NotSupported=nicht unterstützter Dateityp:\ +SummaryViewer.NoSummary=No summary available[TBT] SummaryViewer.Title=AI-generated summary. Check all information. [TBT] SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_es_AR.properties b/iped-app/resources/localization/iped-viewer-messages_es_AR.properties index f7c828bd6c..a284e77713 100644 --- a/iped-app/resources/localization/iped-viewer-messages_es_AR.properties +++ b/iped-app/resources/localization/iped-viewer-messages_es_AR.properties @@ -129,5 +129,6 @@ ProgressDialog.Progress=Progreso ProgressDialog.Searching=Buscando... ReferenceViewer.FileNotFound=Archivo referencia no encontrado:\ ReferenceViewer.NotSupported=Tipo de archivo no admitido:\ +SummaryViewer.NoSummary=No summary available[TBT] SummaryViewer.Title=AI-generated summary. Check all information. [TBT] SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties b/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties index 8db1f6c4dc..3fdd8c21f3 100644 --- a/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties +++ b/iped-app/resources/localization/iped-viewer-messages_fr_FR.properties @@ -129,5 +129,6 @@ ProgressDialog.Progress=En cours ProgressDialog.Searching=Recherche... ReferenceViewer.FileNotFound=Fichier de référence introuvable :\ ReferenceViewer.NotSupported=Type de fichier non supporté :\ +SummaryViewer.NoSummary=No summary available[TBT] SummaryViewer.Title=AI-generated summary. Check all information. [TBT] SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_it_IT.properties b/iped-app/resources/localization/iped-viewer-messages_it_IT.properties index 40e0a2fefb..d1ea45fd88 100644 --- a/iped-app/resources/localization/iped-viewer-messages_it_IT.properties +++ b/iped-app/resources/localization/iped-viewer-messages_it_IT.properties @@ -129,5 +129,6 @@ ProgressDialog.Progress=Avanzamento ProgressDialog.Searching=Ricerca... ReferenceViewer.FileNotFound=File di riferimento non trovato:\ ReferenceViewer.NotSupported=Tipo di file non supportato:\ +SummaryViewer.NoSummary=No summary available[TBT] SummaryViewer.Title=AI-generated summary. Check all information. [TBT] SummaryViewer.TabName=Summary[TBT] diff --git a/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties b/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties index a94b0674e7..35ad5e57f3 100644 --- a/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties +++ b/iped-app/resources/localization/iped-viewer-messages_pt_BR.properties @@ -129,5 +129,6 @@ ProgressDialog.Progress=Progresso ProgressDialog.Searching=Pesquisando... ReferenceViewer.FileNotFound=Arquivo Referenciado não encontrado:\ ReferenceViewer.NotSupported=Tipo de arquivo não suportado:\ +SummaryViewer.NoSummary=Resumo não disponível SummaryViewer.Title=Resumo gerado por IA. Verifique as informações. SummaryViewer.TabName=Resumo diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java index 8aab76a4d9..a74ac7ad9d 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -64,7 +64,7 @@ public void loadFile(final IStreamSource content, String contentType, final Set< javafx.application.Platform.runLater(() -> { if (!(content instanceof IItemReader) || !hasSummary(content)) { - webEngine.loadContent(UiUtil.getUIEmptyHtml("[No summary available]")); + webEngine.loadContent(UiUtil.getUIEmptyHtml("[" + Messages.getString("SummaryViewer.NoSummary") + "]")); return; } From 57d78bce5116a5317341e48a46093e729d0fa02c Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Wed, 28 Jan 2026 16:53:06 -0300 Subject: [PATCH 027/143] '#2641: Show analyzed chats in the AI panel grouped by question score. --- .../config/conf/AIFiltersConfig.json | 6 +++ .../localization/iped-ai-filters.properties | 7 +++ .../iped-ai-filters_de_DE.properties | 7 +++ .../iped-ai-filters_es_AR.properties | 7 +++ .../iped-ai-filters_fr_FR.properties | 7 +++ .../iped-ai-filters_it_IT.properties | 7 +++ .../iped-ai-filters_pt_BR.properties | 7 +++ .../java/iped/app/ui/ai/AIFiltersLoader.java | 41 ++++++++++++++++++ .../app/ui/ai/AIFiltersTreeCellRenderer.java | 4 ++ .../app/ui/filter/Analysis.Analyzed Chats.png | Bin 0 -> 486 bytes .../iped/app/ui/filter/Analysis.High.png | Bin 0 -> 505 bytes .../iped/app/ui/filter/Analysis.Low.png | Bin 0 -> 444 bytes .../iped/app/ui/filter/Analysis.Medium.png | Bin 0 -> 481 bytes .../iped/app/ui/filter/Analysis.Very High.png | Bin 0 -> 476 bytes .../iped/app/ui/filter/Analysis.Very Low.png | Bin 0 -> 394 bytes .../iped/engine/data/SimpleFilterNode.java | 13 +++++- 16 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 iped-app/src/main/resources/iped/app/ui/filter/Analysis.Analyzed Chats.png create mode 100644 iped-app/src/main/resources/iped/app/ui/filter/Analysis.High.png create mode 100644 iped-app/src/main/resources/iped/app/ui/filter/Analysis.Low.png create mode 100644 iped-app/src/main/resources/iped/app/ui/filter/Analysis.Medium.png create mode 100644 iped-app/src/main/resources/iped/app/ui/filter/Analysis.Very High.png create mode 100644 iped-app/src/main/resources/iped/app/ui/filter/Analysis.Very Low.png diff --git a/iped-app/resources/config/conf/AIFiltersConfig.json b/iped-app/resources/config/conf/AIFiltersConfig.json index 877870c369..eff256cfe1 100644 --- a/iped-app/resources/config/conf/AIFiltersConfig.json +++ b/iped-app/resources/config/conf/AIFiltersConfig.json @@ -47,6 +47,12 @@ {"name": "Low", "value": "[1 TO 99]"} ]}, + {"name": "Analyzed Chats", "prefix": "Analysis", "property": "ai\\:analysis\\:*", "value": "[300 TO *]", "children":[ + {"name": "Very High", "value": "[800 TO *]"}, + {"name": "High", "value": "[600 TO 799]"}, + {"name": "Medium", "value": "[300 TO 599]"} + ]}, + {"name": "Summarized Chats", "prefix": "Summary", "property": "ai\\:summary", "value": "*"}, {"name": "Transcribed Audio", "prefix": "Transcript", "property": "audio\\:transcription", "value": "*"}, diff --git a/iped-app/resources/localization/iped-ai-filters.properties b/iped-app/resources/localization/iped-ai-filters.properties index 6553cf5cec..f31aa7c2fa 100644 --- a/iped-app/resources/localization/iped-ai-filters.properties +++ b/iped-app/resources/localization/iped-ai-filters.properties @@ -38,6 +38,13 @@ OCR.High=High (more than 1000) OCR.Medium=Medium (100 to 1000) OCR.Low=Low (less than 100) +Analysis.Analyzed\ Chats=Analyzed Chats +Analysis.Very\ High=Very High +Analysis.High=High +Analysis.Medium=Medium +Analysis.Low=Low +Analysis.Very\ Low=Very Low + Summary.Summarized\ Chats=Summarized Chats Transcript.Transcribed\ Audio=Transcribed Audio diff --git a/iped-app/resources/localization/iped-ai-filters_de_DE.properties b/iped-app/resources/localization/iped-ai-filters_de_DE.properties index 0135424d01..1da496cd36 100644 --- a/iped-app/resources/localization/iped-ai-filters_de_DE.properties +++ b/iped-app/resources/localization/iped-ai-filters_de_DE.properties @@ -38,6 +38,13 @@ OCR.High=hoch (mehr als 1000) OCR.Medium=mittel (100 bis 1000) OCR.Low=niedrig (weniger als 100) +Analysis.Analyzed\ Chats=Analyzed Chats[TBT] +Analysis.Very\ High=Sehr hoch +Analysis.High=hoch +Analysis.Medium=mittel +Analysis.Low=niedrig +Analysis.Very\ Low=sehr niedrig + Summary.Summarized\ Chats=Summarized Chats[TBT] Transcript.Transcribed\ Audio=verschriftetes Audio diff --git a/iped-app/resources/localization/iped-ai-filters_es_AR.properties b/iped-app/resources/localization/iped-ai-filters_es_AR.properties index c7be8d0b62..a64b81f429 100644 --- a/iped-app/resources/localization/iped-ai-filters_es_AR.properties +++ b/iped-app/resources/localization/iped-ai-filters_es_AR.properties @@ -38,6 +38,13 @@ OCR.High=Alt (más de 1000) OCR.Medium=Medio (entre 100 y 1000) OCR.Low=Bajo (mnenos de 100) +Analysis.Analyzed\ Chats=Analyzed Chats[TBT] +Analysis.Very\ High=Muy Alto +Analysis.High=Alto +Analysis.Medium=Medio +Analysis.Low=Bajo +Analysis.Very\ Low=Muy bajo + Summary.Summarized\ Chats=Summarized Chats[TBT] Transcript.Transcribed\ Audio=Audio Transcrito diff --git a/iped-app/resources/localization/iped-ai-filters_fr_FR.properties b/iped-app/resources/localization/iped-ai-filters_fr_FR.properties index 97a329c498..718c946575 100644 --- a/iped-app/resources/localization/iped-ai-filters_fr_FR.properties +++ b/iped-app/resources/localization/iped-ai-filters_fr_FR.properties @@ -38,6 +38,13 @@ OCR.High=Élevé (plus de 1000) OCR.Medium=Moyen (100 à 1000) OCR.Low=Bas (moins de 100) +Analysis.Analyzed\ Chats=Analyzed Chats[TBT] +Analysis.Very\ High=Très Élevé +Analysis.High=Élevé +Analysis.Medium=Moyen +Analysis.Low=Bas +Analysis.Very\ Low=Très Bas + Summary.Summarized\ Chats=Summarized Chats[TBT] Transcript.Transcribed\ Audio=Transcription Audio diff --git a/iped-app/resources/localization/iped-ai-filters_it_IT.properties b/iped-app/resources/localization/iped-ai-filters_it_IT.properties index 5f29f4991e..04f2735505 100644 --- a/iped-app/resources/localization/iped-ai-filters_it_IT.properties +++ b/iped-app/resources/localization/iped-ai-filters_it_IT.properties @@ -38,6 +38,13 @@ OCR.High=Alto (più di 1000) OCR.Medium=Medio (da 100 a 1000) OCR.Low=Basso (meno di 100) +Analysis.Analyzed\ Chats=Analyzed Chats[TBT] +Analysis.Very\ High=Molto Alto +Analysis.High=Alto +Analysis.Medium=Medio +Analysis.Low=Basso +Analysis.Very\ Low=Molto Basso + Summary.Summarized\ Chats=Summarized Chats[TBT] Transcript.Transcribed\ Audio=Audio Trascritto diff --git a/iped-app/resources/localization/iped-ai-filters_pt_BR.properties b/iped-app/resources/localization/iped-ai-filters_pt_BR.properties index 4d8ce83510..138872441e 100644 --- a/iped-app/resources/localization/iped-ai-filters_pt_BR.properties +++ b/iped-app/resources/localization/iped-ai-filters_pt_BR.properties @@ -38,6 +38,13 @@ OCR.High=Alta (mais de 1000) OCR.Medium=Média (100 a 1000) OCR.Low=Baixa (menos que 100) +Analysis.Analyzed\ Chats=Chats Analisados +Analysis.Very\ High=Muito Alto +Analysis.High=Alto +Analysis.Medium=Médio +Analysis.Low=Baixo +Analysis.Very\ Low=Muito Baixo + Summary.Summarized\ Chats=Chats Resumidos Transcript.Transcribed\ Audio=Áudios Transcritos diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersLoader.java b/iped-app/src/main/java/iped/app/ui/ai/AIFiltersLoader.java index baaa3778dc..0814f426d0 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersLoader.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIFiltersLoader.java @@ -8,6 +8,9 @@ import java.util.Map; import org.apache.lucene.document.Document; +import org.apache.lucene.queryparser.flexible.standard.QueryParserUtil; +import org.apache.lucene.queryparser.flexible.standard.parser.EscapeQuerySyntaxImpl; +import org.apache.lucene.queryparser.flexible.standard.parser.ParseException; import org.apache.lucene.search.IndexSearcher; import org.apache.lucene.search.Query; @@ -18,6 +21,7 @@ import iped.engine.data.IPEDSource; import iped.engine.data.SimpleFilterNode; import iped.engine.search.IPEDSearcher; +import iped.engine.search.LoadIndexFields; import iped.engine.search.LuceneSearchResult; import iped.engine.search.SimpleNodeFilterSearch; @@ -26,6 +30,7 @@ public static void load() { AIFiltersConfig config = ConfigurationManager.get().findObject(AIFiltersConfig.class); SimpleFilterNode root = (SimpleFilterNode) config.getRootAIFilter().clone(); + expandWildcards(root); sortChildrenNodes(root, (Comparator) new SimpleFilterNode.LocalizedNameComparator()); updateCount(App.get().appCase, root); removeEmptyTopLevel(root); @@ -43,6 +48,42 @@ private static void sortChildrenNodes(SimpleFilterNode node, Comparator= 0) { text += " (" + LocalizedFormat.format(numItems) + ")"; } diff --git a/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Analyzed Chats.png b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Analyzed Chats.png new file mode 100644 index 0000000000000000000000000000000000000000..a0eab8001f1418a82865476cdb1bb3ec6448229b GIT binary patch literal 486 zcmV@P)CWrKPJtXfw2l2!)^)B}Am*$3j{} z1w!0BmuBXE&7Dp&&20L^AN8EWIlq~6N7OKk6_Tu_K-~U*Q!K6$(>#jBFiQe}M8E$S zi(!^8z+mt~#^bj?G4u`ufI^H)rBjkl?~!u(OiW3F-jM+q#;&jZ6d8axP-T@O0bqyJ zYL{HDP&m;-paE?Anbhl7)SyPA?>6@XtyIq4n&I%3%b@|P)r)0rgBql|Iw$Yc@O9g6 z41@*{zaX(&xC2(IDoK%?~70X zek%wv+&M?N+~My5^efPL&Ful|^=2fK*(Z|;@(e^R^dXE|{{TM%Nc?Ac999A({?{xy cOM!U(1?z6?C}wu01^@s607*qoM6N<$f?!438UO$Q literal 0 HcmV?d00001 diff --git a/iped-app/src/main/resources/iped/app/ui/filter/Analysis.High.png b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.High.png new file mode 100644 index 0000000000000000000000000000000000000000..cde5186aa38754a6ec6466ec7e07791bdd1272ef GIT binary patch literal 505 zcmVT z5g_t^ATa~F0KGBX0hf!N|KFQlOcxBY3&Q~*i7$6A(glO;!s&p&Z}0#A_xTk~Fvx6z z4ge`61$i)ggl)JDU({K0ZT{BiR8z_xJq&R3`z( zAb`zxFnO376m`vta4`@d*#YPPZg7(vx)ch)=1>qI*#YQL05`Z*70myAWfNQ;g#Uec zgQ|{F2cSp6rv_;ZgMXb{4wnD>={Z~srsm5uXSf&$kn8|#0GIDz0G~X`4nU5A-#2%I z#X#|oY{{2iV@N18$)kv4a|Fo_K#l^GRF1~MWn6GGK;X~w%l|)jY5_}164L;+0m+Gw v+F;ZHL^)uDAuT literal 0 HcmV?d00001 diff --git a/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Low.png b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Low.png new file mode 100644 index 0000000000000000000000000000000000000000..0d2c6789a7bd36c7a477c860c7d40a74e19ca1de GIT binary patch literal 444 zcmV;t0Ym!ONnubpw?y+S ziA9|(-x-UV6xEOc0{4wsPgjLOs=MQPUL)68gBuY8kpZy28(-tpo>ZV9G5}ryZoKhb zkq2k#?zgy(iwuBQ09V|2y>yDWyl-CfG3tvLhzy{T?(Vouc`|^!f+(05G1$L6#Cf^f zJp~k3L68CD707S7ZkWzoWB`-$JTDD08AJwm<)hdAba}QEskN9CM;Tc z#dWo?C5ZBxma`EJ;7}b5pj$ze;mL8qDfuygX$2-v9xg=>&^8L$Y8z~2C9 m{juu3MD=0000P;HZMw#-f5$TjFm_(p;J| z?@F$Bm%D^#V}jrpzLdWAc<+Oq64GOh@!$^}3iLhLOm z=iM)-LZAV9&KV`o+sq*HvcsE)q|NI@`JD}+rX}Uj0L0#;Zhf2?q`SH}AJ<5AjTs&T zp#fA48c(SyNjgvv8bDpapmCM`4?Yfg9vlX?Qa2<(Vg>byL}T*4&+;L8FQ#o<0u3OpKzhmz!%U8a21r#%5`+4# zYGQo|G=R7Q(OH7?uL6M40O|@vJJ+MpF+7Nl-Wv_RPS`Jb-4!4v699gD9S1P1Tx)hX z0MiOG9o;z&(OmjDK-vn@>|BlT9-y9g8NIYD;GcoK^$+kz0Lp$XFTz@Y(m&10a_Ioy X_E23ngciC`00000NkvXXu0mjfZ=TUc literal 0 HcmV?d00001 diff --git a/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Very High.png b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Very High.png new file mode 100644 index 0000000000000000000000000000000000000000..7db4f359c6b07e1718cd4b077a6807c8d70f957c GIT binary patch literal 476 zcmV<20VDp2P)@5XP-7#X=AjK}BrEC$RKEd;o22tStmP8%t|#tt>4)%PVXSMX|6lo*T25SuX4T zP1s8=;ov^_2+j;MzhpL|skdwvC+U3PZ0}uzQtWqQZ><}~_bGdt`;|pO7 z@Z4-LnoNj6Xga0Mozscdv3d-LubRVw%NYZp(Fj|)95G0BcV6DB;p)m|8Uq;vh@y>) zMKQ%m1qw0-5LZAOSF7Z+ys=%&WP(U%9M|0aVi6 zohQG%F#x*)d$l4l*greOYBsSAPoY%e;|d640Col3Dc20s*^@DVuMm<35xWOeihV;E z1F$P#9VB@FMF6ldfVcwIw@e1xiv@PBS8T7>RL4;O literal 0 HcmV?d00001 diff --git a/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Very Low.png b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Very Low.png new file mode 100644 index 0000000000000000000000000000000000000000..8e5360c5a80d70c15ebfc0bf45686c6791169094 GIT binary patch literal 394 zcmV;50d@X~P)Uyw&_<0r}>~dI=$=j`C}KO*8>0oLX^80y`lh0je45YuNbvA0g%X1(^R^uZn|v+4Lu08lL4S< z%rPYcpbbL~DG7kzr0<(Wtgeey2sXet%I_wvF@&ovu2dMb>*{C@8vr{j%d0UI+MSKt zoLC;j2B4Y?&&@(g20%wZ3rpF%IBY*l^Q_nabOgB4!o$#+b${MAM~F|i2C)G`itWy7 zEzSn;MH;dVd4X~zZ`q>lFhhPKvBk;W?+T5BA zKu56j#oUI-$N9k3hYOtvJSABGuj*xh*a)^gzoc-NJPoiug1C#~;%Y$H$Wvz^wf+JA o20-?o<$E{?ko~V&E@ub$0$m{WWiME;!~g&Q07*qoM6N<$f`I(2SpWb4 literal 0 HcmV?d00001 diff --git a/iped-engine/src/main/java/iped/engine/data/SimpleFilterNode.java b/iped-engine/src/main/java/iped/engine/data/SimpleFilterNode.java index 0ce27ae936..53d4a2e10f 100644 --- a/iped-engine/src/main/java/iped/engine/data/SimpleFilterNode.java +++ b/iped-engine/src/main/java/iped/engine/data/SimpleFilterNode.java @@ -8,7 +8,6 @@ import java.util.ResourceBundle; import java.text.Collator; import iped.localization.LocaleResolver; -import iped.localization.Messages; import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -49,6 +48,9 @@ public class SimpleFilterNode implements Serializable, Cloneable { @JsonIgnore private int numItems = -1; + @JsonIgnore + private String suffix; + public String getName() { return name; } @@ -66,6 +68,10 @@ public String getPrefix() { return s; } + public String getSuffix() { + return suffix; + } + public String getProperty() { String s = property; if (s == null && parent != null) { @@ -111,6 +117,10 @@ public void setName(String name) { this.name = name; } + public void setSuffix(String suffix) { + this.suffix = suffix; + } + public void setPrefix(String prefix) { this.prefix = prefix; } @@ -169,6 +179,7 @@ public Object clone() { clonedNode.addChildren = addChildren; clonedNode.sortChildren = sortChildren; clonedNode.dynamicChild = dynamicChild; + clonedNode.suffix = suffix; for (int i = 0; i < children.size(); i++) { SimpleFilterNode child = children.get(i); SimpleFilterNode clonedChild = (SimpleFilterNode) child.clone(); From e7c350f275558f08c1fccd58068238d8c9cfc532 Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Thu, 29 Jan 2026 08:03:59 -0300 Subject: [PATCH 028/143] '#2641: Additional comment about question attributes for chat analysis. --- iped-app/resources/config/conf/AISummarizationConfig.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/iped-app/resources/config/conf/AISummarizationConfig.txt b/iped-app/resources/config/conf/AISummarizationConfig.txt index 12849b24b9..89d0b6106f 100644 --- a/iped-app/resources/config/conf/AISummarizationConfig.txt +++ b/iped-app/resources/config/conf/AISummarizationConfig.txt @@ -19,5 +19,8 @@ minimumContentLength = 1000 # and the returned scores (0-999) will be available as attributes listed in 'questionAttributes'. enableChatAnalysis = false +# It is recommended to use the 'Score' suffix for attributes, e.g., 'fraudScore'. +# In this case, the property would be named 'ai:analysis:fraudScore', and an entry named +# 'Analyzed Chats - Fraud' would be available in the AI panel, grouping chats by score level. questions = ["Question1?", "Question2?"] questionAttributes = ["question1Score", "question2Score"] From 2ccbac0d62d2bc68db042dffffc8495d08fa477a Mon Sep 17 00:00:00 2001 From: wladimirleite Date: Thu, 29 Jan 2026 15:45:08 -0300 Subject: [PATCH 029/143] '#2641: Reverts commit b8e359b. --- .../java/iped/viewers/components/HitsTable.java | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/components/HitsTable.java b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/components/HitsTable.java index 5958fa3f2a..41a8c10bce 100644 --- a/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/components/HitsTable.java +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/components/HitsTable.java @@ -18,12 +18,10 @@ */ package iped.viewers.components; -import java.awt.Color; import java.awt.Dimension; import javax.swing.JTable; import javax.swing.ListSelectionModel; -import javax.swing.UIManager; import javax.swing.table.AbstractTableModel; public class HitsTable extends JTable { @@ -44,16 +42,4 @@ public void changeSelection(int rowIndex, int columnIndex, boolean toggle, boole super.changeSelection(rowIndex, 0, toggle, extend); } - @Override - public void updateUI() { - Color background = UIManager.getColor("Viewer.background"); - if (background == null) - background = Color.WHITE; - setBackground(background); - Color foreground = UIManager.getColor("Viewer.foreground"); - if (foreground == null) - foreground = Color.BLACK; - setForeground(foreground); - super.updateUI(); - } } From d2e81eee6495411512e200ae194d26b662452657 Mon Sep 17 00:00:00 2001 From: mbichara Date: Tue, 17 Mar 2026 13:05:24 -0300 Subject: [PATCH 030/143] #2641: Add connection params, fix attachment parsing and error handling --- .../scripts/tasks/AISummarizationTask.py | 156 +++++++----------- 1 file changed, 63 insertions(+), 93 deletions(-) diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index e44b0c4ec3..54df297012 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -9,6 +9,7 @@ import re from datetime import datetime from typing import List, Any, Dict, Tuple, Optional +from iped.exception import IPEDException # configuration properties enableProp = 'enableAISummarization' @@ -22,15 +23,14 @@ BUSY_SLEEP_TIME = 10.0 NONBUSY_SLEEP_TIME = 1.0 MAX_ATTEMPTS_NONBUSY_RETRIES = 10 +CONNECTION_TIMEOUT = 5 +REQUEST_TIMEOUT = 7200 # 7200 seconds = 2 hours # NEW: chat analysis / questions config enableChatAnalysisProp = 'enableChatAnalysis' questionsProp = 'questions' questionAttributesProp = 'questionAttributes' -# bookmarks config - Just for testing purposes, set to False to test bookmarks creation -# I believe it is better to show AI analysis results in the AI panel. -analysisBookmarksCreated = True def _parse_list_prop(raw: Optional[str]) -> List[str]: @@ -140,6 +140,8 @@ def create_summaries_request( BUSY_SLEEP: float = BUSY_SLEEP_TIME, NONBUSY_SLEEP: float = NONBUSY_SLEEP_TIME, MAX_ATTEMPTS_NONBUSY: int = MAX_ATTEMPTS_NONBUSY_RETRIES, + CONNECTION_TIMEOUT: int = CONNECTION_TIMEOUT, + REQUEST_TIMEOUT: int = REQUEST_TIMEOUT, ) -> Dict[str, Any]: """ - BUSY (code == 'BUSY'): retry forever, sleeping BUSY_SLEEP each time. @@ -147,6 +149,7 @@ def create_summaries_request( NOTE: Logging is improved; logic is unchanged. """ + #Trocar para https url = f"http://{base_url}/api/create_summaries_from_msgs" attempts_other = 0 @@ -155,7 +158,7 @@ def create_summaries_request( payload: Dict[str, Any] = {"msgs": msgs} if questions: # NEW payload["questions"] = questions - resp = requests.post(url, json=payload) + resp = requests.post(url, json=payload, timeout=(CONNECTION_TIMEOUT, REQUEST_TIMEOUT)) res = _normalize(resp) except requests.exceptions.Timeout: res = { @@ -164,6 +167,9 @@ def create_summaries_request( "http_status": 408, "message": "Request timed out.", } + logger.error(f"[AISummarizationTask]: Error - Request timed out after long time: {REQUEST_TIMEOUT} seconds.") + res["ok"] = False + return res except requests.exceptions.ConnectionError as e: res = { "ok": False, @@ -178,6 +184,13 @@ def create_summaries_request( "http_status": 500, "message": str(e), } + except Exception as e: + res = { + "ok": False, + "code": "UNKNOWN_ERROR", + "http_status": 500, + "message": str(e), + } if res.get("ok"): return res @@ -194,11 +207,12 @@ def create_summaries_request( attempts_other += 1 if attempts_other >= MAX_ATTEMPTS_NONBUSY: # Final error log with nice formatting - logger.error(f"[AISummarizationTask]: Error - {_fmt_error(res)}") + logger.error(f"[AISummarizationTask]: Error - tried {attempts_other} times - {_fmt_error(res)}") + res["ok"] = False return res # Intermediate warning with nice formatting - logger.warn(f"[AISummarizationTask]: Warning - {_fmt_error(res)}") + logger.warn(f"[AISummarizationTask]: Warning - tried {attempts_other} times - {_fmt_error(res)}") time.sleep(max(0.1, NONBUSY_SLEEP)) @@ -286,15 +300,21 @@ def getMessagesFromChatHTML(html_text: str) -> Tuple[List[Dict], int]: if msg_div.find("span", class_=["fwd"]): forwarded = True - name = msg_div.find("span").get_text(" ", strip=True) + name_span = msg_div.find("span", class_="name") or msg_div.find("span") + if name_span and "time" in (name_span.get("class") or []): + name_span = None + name = name_span.get_text(" ", strip=True) if name_span else "" + #print(msg_div.prettify()) timestamp_span = msg_div.find("span", class_="time") if not timestamp_span: logger.warn(f"[AISummarizationTask]: Warning - No span timestamp in {msg_div.prettify()}") - timestamp = None - else: - timestamp_raw = timestamp_span.get_text(" ", strip=True) - timestamp = _clean_timestamp(timestamp_raw) if timestamp_raw else None + continue # melhor do que timestamp=None + timestamp_raw = timestamp_span.get_text(" ", strip=True) + timestamp = _clean_timestamp(timestamp_raw) if timestamp_raw else None + if not timestamp: + logger.warn(f"[AISummarizationTask]: Warning - No span timestamp in {msg_div.prettify()}") + continue # -------------------------------------------------------------------# # 1) transcrição de áudio (fica em ) @@ -311,46 +331,53 @@ def getMessagesFromChatHTML(html_text: str) -> Tuple[List[Dict], int]: # -------------------------------------------------------------------# # 2) anexo (áudio / vídeo / outro) # -------------------------------------------------------------------# - if not content: + if not content and not kind: #kind = "other" # áudio ➜ ícone
if msg_div.find("div", class_="audioImg"): kind = "audio" - content = f" " elif msg_div.find("div", class_="imageImg"): kind = "image" - content = f" " elif msg_div.find("div", class_="videoImg"): kind = "video" - content = f" " # vídeo ou imagem ➜ thumbnail else: thumb = msg_div.find("img", class_="thumb") - if thumb and thumb.get("title"): - title = thumb["title"].lower() + if thumb: + title = (thumb.get("title") or "").lower() if "video" in title: kind = "video" - content = f" " elif "image" in title: kind = "image" - content = f" " + elif "file" in title: + kind = "file" + else: + kind = "attachment" # fallback #a_tag = msg_div.find("a", href=True) #if a_tag: # content = f" " + + if kind and not content: + caption = _extract_text_nodes(msg_div) # pode vir vazio + if caption: + content = caption + # -------------------------------------------------------------------# # 3) texto “puro” # -------------------------------------------------------------------# - if not content: + if not content and not kind: content = _extract_text_nodes(msg_div) kind = "text" # ainda vazio? provavelmente só thumbs ou attachments sem link → pula - if not content: - continue + if kind in ("text", "audio transcription"): + content = (content or "").strip() + if not content: + continue len_mgs_content = len_mgs_content + len(content) @@ -411,7 +438,8 @@ def init(self, configuration): if not self.remoteServiceAddress: logger.error('[AISummarizationTask]: Error: Task enabled but remoteServiceAddress not set on config file.') self.enabled = False - return + self.worker.exception = IPEDException(f"[AISummarizationTask]: Error: Task enabled but remoteServiceAddress not set on config file.") + raise Exception(f"[AISummarizationTask]: Error: Task enabled but remoteServiceAddress not set on config file.") self.enableInternalSummarization = (extraProps.getProperty(enableInternalSummarizationProp) or "").lower() == "true" self.enableExternalSummarization = (extraProps.getProperty(enableExternalSummarizationProp) or "").lower() == "true" @@ -426,12 +454,14 @@ def init(self, configuration): if self.enableChatAnalysis and (len(self.questions) == 0 or len(self.questionAttributes) == 0): - logger.error("[AISummarizationTask]: Warning - 'questions' and 'questionAttributes' are not set.") - raise Exception(f"[AISummarizationTask]: Warning - 'questions' and 'questionAttributes' are not set.") + logger.error("[AISummarizationTask]: Error - 'questions' and 'questionAttributes' are not set.") + self.worker.exception = IPEDException(f"[AISummarizationTask]: Error - 'questions' and 'questionAttributes' are not set.") + raise Exception(f"[AISummarizationTask]: Error - 'questions' and 'questionAttributes' are not set.") if self.enableChatAnalysis and len(self.questions) != len(self.questionAttributes): - logger.error("[AISummarizationTask]: Warning - 'questions' and 'questionAttributes' have different sizes.") - raise Exception(f"[AISummarizationTask]: Warning - 'questions' and 'questionAttributes' have different sizes.") + logger.error("[AISummarizationTask]: Error - 'questions' and 'questionAttributes' have different sizes.") + self.worker.exception = IPEDException(f"[AISummarizationTask]: Error - 'questions' and 'questionAttributes' have different sizes.") + raise Exception(f"[AISummarizationTask]: Error - 'questions' and 'questionAttributes' have different sizes.") return @@ -453,7 +483,7 @@ def processChat(self, item): chatHtml = bytes(b & 0xFF for b in raw_bytes).decode('utf-8', errors='replace') msgs, total_len = getMessagesFromChatHTML(chatHtml) - if len(msgs) == 0 or total_len < self.minimumContentLength: + if len(msgs) == 0 or total_len == 0 or total_len < self.minimumContentLength: return questions = None @@ -469,6 +499,7 @@ def processChat(self, item): if not res["ok"]: logger.error(f"[AISummarizationTask]: Error {item.getName()} - {res['code']} ({res['http_status']}): {res.get('message')}") # Exit - server connection problem + self.worker.exception = IPEDException(f"[AISummarizationTask]: Error {item.getName()} - {res['code']} ({res['http_status']}): {res.get('message')}") raise Exception(f"[AISummarizationTask]: Error {item.getName()} - {res['code']} ({res['http_status']}): {res.get('message')}") @@ -524,7 +555,7 @@ def processChat(self, item): if not isinstance(answers, list): # If this chunk has no answers list, append "0" for each attribute to keep lengths aligned for attr in self.questionAttributes: - per_attr_answers[attr].append("0") + per_attr_answers[attr].append(0) continue # For each question index, append its answer to the corresponding attribute array @@ -533,7 +564,7 @@ def processChat(self, item): per_attr_answers[attr_name].append(int(answers[q_idx])) else: # No answer for this question in this chunk → default "0" - per_attr_answers[attr_name].append("0") + per_attr_answers[attr_name].append(0) if len(chunk_summaries) == 0: logger.error( @@ -581,66 +612,5 @@ def process(self, item): # Called when task processing is finished. Can be used to cleanup resources. # Objects "ipedCase" and "searcher" are provided, so case can be queried for items and bookmarks can be created. def finish(self): - """ - Creates bookmarks for all ai:analysis:* attributes after processing. - One pair of bookmarks per questionAttribute: - - Todos os analisados (score >= 0) - - Alta relevância (score >= 750) - """ - from iped.properties import ExtraProperties - - global analysisBookmarksCreated - if analysisBookmarksCreated: - return - - # If analysis is disabled or there are no configured attributes, do nothing - if not getattr(self, "enableChatAnalysis", False) or not getattr(self, "questionAttributes", None): - return - - try: - # Loop over each configured question attribute - for attr_name in self.questionAttributes: - # ai:analysis: → ai\\:analysis\\: in Lucene query - field = f"ai\\:analysis\\:{attr_name}" - - # All analyzed (score >= 0) - query_all = f"{field}: [0 TO *]" - - # High relevance (score >= 750) - query_high = f"{field}: [750 TO *]" - - # ---- Bookmark: all analyzed for this attribute ---- - searcher.setQuery(query_all) - ids = searcher.search().getIds() - - if len(ids) > 0: - bookmarkId = ipedCase.getBookmarks().newBookmark( - f"IA: {attr_name} - todos analisados" - ) - ipedCase.getBookmarks().setBookmarkComment( - bookmarkId, - f"{len(ids)} chats possuem valores em {field} (score >= 0)." - ) - ipedCase.getBookmarks().addBookmark(ids, bookmarkId) - - # ---- Bookmark: high relevance for this attribute ---- - searcher.setQuery(query_high) - ids = searcher.search().getIds() - - if len(ids) > 0: - bookmarkId = ipedCase.getBookmarks().newBookmark( - f"IA: {attr_name} - alta relevância" - ) - ipedCase.getBookmarks().setBookmarkComment( - bookmarkId, - f"{len(ids)} chats possuem alta relevância em {field} (score >= 750)." - ) - ipedCase.getBookmarks().addBookmark(ids, bookmarkId) - - # Persist all bookmark changes once at the end - ipedCase.getBookmarks().saveState(True) - - analysisBookmarksCreated = True - - except Exception as e: - logger.error(f"[AISummarizationTask]: Error creating analysis bookmarks in finish(): {e}") + return + \ No newline at end of file From bb9c017b35ea851abc97b56ade628a3f773255d1 Mon Sep 17 00:00:00 2001 From: mbichara Date: Thu, 26 Mar 2026 16:22:06 -0300 Subject: [PATCH 031/143] #2641: Put connection params on config --- .../config/conf/AISummarizationConfig.txt | 13 +++- .../scripts/tasks/AISummarizationTask.py | 67 ++++++++++++++++++- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/iped-app/resources/config/conf/AISummarizationConfig.txt b/iped-app/resources/config/conf/AISummarizationConfig.txt index 89d0b6106f..03410ef85f 100644 --- a/iped-app/resources/config/conf/AISummarizationConfig.txt +++ b/iped-app/resources/config/conf/AISummarizationConfig.txt @@ -3,7 +3,14 @@ ############################################################################## # IP:PORT of the service/central node used by the AISummarizationTask implementation. -remoteServiceAddress = 127.0.0.1:8000 +# remoteServiceAddress = 127.0.0.1:8000 + +# Remote service communication parameters used by AISummarizationTask. +busySleepTime = 10.0 +nonBusySleepTime = 1.0 +maxAttemptsNonBusyRetries = 10 +connectionTimeout = 5 +requestTimeout = 7200 # Enables automatic summarization of internally parsed chats (WhatsApp, Telegram etc.) using AI models. enableInternalChatSummarization = true @@ -22,5 +29,5 @@ enableChatAnalysis = false # It is recommended to use the 'Score' suffix for attributes, e.g., 'fraudScore'. # In this case, the property would be named 'ai:analysis:fraudScore', and an entry named # 'Analyzed Chats - Fraud' would be available in the AI panel, grouping chats by score level. -questions = ["Question1?", "Question2?"] -questionAttributes = ["question1Score", "question2Score"] +# questions = ["Question1?", "Question2?"] +# questionAttributes = ["question1Score", "question2Score"] diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py index 54df297012..ad8577fc53 100644 --- a/iped-app/resources/scripts/tasks/AISummarizationTask.py +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -26,6 +26,13 @@ CONNECTION_TIMEOUT = 5 REQUEST_TIMEOUT = 7200 # 7200 seconds = 2 hours +# Remote service communication parameters (configurable props) +busySleepTimeProp = 'busySleepTime' +nonBusySleepTimeProp = 'nonBusySleepTime' +maxAttemptsNonBusyRetriesProp = 'maxAttemptsNonBusyRetries' +connectionTimeoutProp = 'connectionTimeout' +requestTimeoutProp = 'requestTimeout' + # NEW: chat analysis / questions config enableChatAnalysisProp = 'enableChatAnalysis' questionsProp = 'questions' @@ -150,7 +157,7 @@ def create_summaries_request( NOTE: Logging is improved; logic is unchanged. """ #Trocar para https - url = f"http://{base_url}/api/create_summaries_from_msgs" + url = f"https://{base_url}/api/create_summaries_from_msgs" attempts_other = 0 while True: @@ -158,7 +165,7 @@ def create_summaries_request( payload: Dict[str, Any] = {"msgs": msgs} if questions: # NEW payload["questions"] = questions - resp = requests.post(url, json=payload, timeout=(CONNECTION_TIMEOUT, REQUEST_TIMEOUT)) + resp = requests.post(url, json=payload, verify=False, timeout=(CONNECTION_TIMEOUT, REQUEST_TIMEOUT)) res = _normalize(resp) except requests.exceptions.Timeout: res = { @@ -410,6 +417,13 @@ def __init__(self): self.enableExternalSummarizationProp = False self.minimumContentLength = 0 + # Remote service communication parameters (defaults can be overridden by props) + self.busySleepTime: float = BUSY_SLEEP_TIME + self.nonBusySleepTime: float = NONBUSY_SLEEP_TIME + self.maxAttemptsNonBusyRetries: int = MAX_ATTEMPTS_NONBUSY_RETRIES + self.connectionTimeout: int = CONNECTION_TIMEOUT + self.requestTimeout: int = REQUEST_TIMEOUT + # NEW: chat analysis / questions self.enableChatAnalysis = False self.questions: List[str] = [] @@ -445,6 +459,48 @@ def init(self, configuration): self.enableExternalSummarization = (extraProps.getProperty(enableExternalSummarizationProp) or "").lower() == "true" self.minimumContentLength = int(extraProps.getProperty(minimumContentLengthProp) or 0) + + # Remote service communication parameters + # Keep existing hard-coded values as defaults when props are absent/invalid. + try: + raw = extraProps.getProperty(busySleepTimeProp) + if raw is not None and str(raw).strip() != "": + self.busySleepTime = float(raw) + except Exception: + logger.warn(f"[AISummarizationTask]: Warning - Invalid '{busySleepTimeProp}', using default {BUSY_SLEEP_TIME}.") + + try: + raw = extraProps.getProperty(nonBusySleepTimeProp) + if raw is not None and str(raw).strip() != "": + self.nonBusySleepTime = float(raw) + except Exception: + logger.warn(f"[AISummarizationTask]: Warning - Invalid '{nonBusySleepTimeProp}', using default {NONBUSY_SLEEP_TIME}.") + + try: + raw = extraProps.getProperty(maxAttemptsNonBusyRetriesProp) + if raw is not None and str(raw).strip() != "": + self.maxAttemptsNonBusyRetries = int(raw) + except Exception: + logger.warn( + f"[AISummarizationTask]: Warning - Invalid '{maxAttemptsNonBusyRetriesProp}', " + f"using default {MAX_ATTEMPTS_NONBUSY_RETRIES}." + ) + + try: + raw = extraProps.getProperty(connectionTimeoutProp) + if raw is not None and str(raw).strip() != "": + self.connectionTimeout = int(raw) + except Exception: + logger.warn( + f"[AISummarizationTask]: Warning - Invalid '{connectionTimeoutProp}', using default {CONNECTION_TIMEOUT}." + ) + + try: + raw = extraProps.getProperty(requestTimeoutProp) + if raw is not None and str(raw).strip() != "": + self.requestTimeout = int(raw) + except Exception: + logger.warn(f"[AISummarizationTask]: Warning - Invalid '{requestTimeoutProp}', using default {REQUEST_TIMEOUT}.") # NEW: chat analysis-related options self.enableChatAnalysis = (extraProps.getProperty(enableChatAnalysisProp) or "").lower() == "true" @@ -493,7 +549,12 @@ def processChat(self, item): res = create_summaries_request( msgs, self.remoteServiceAddress, - questions=questions + questions=questions, + BUSY_SLEEP=self.busySleepTime, + NONBUSY_SLEEP=self.nonBusySleepTime, + MAX_ATTEMPTS_NONBUSY=self.maxAttemptsNonBusyRetries, + CONNECTION_TIMEOUT=self.connectionTimeout, + REQUEST_TIMEOUT=self.requestTimeout, ) if not res["ok"]: From 6a3bad86c054525aa5803a9d3d55ac7b840b823a Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 7 Apr 2026 14:13:41 -0300 Subject: [PATCH 032/143] feat(ui): add standalone AI assistant chat panel - Created `AIAssistantPanel` with a complete visual layout including chat area, input field, progress bar, and quick actions. - Integrated `aiAssistantButton` into the main application toolbar in `App.java`. - Registered `Ctrl+Shift+A` global keyboard shortcut to toggle panel visibility. - Note: This is a presentation-layer only implementation; no LLM or backend logic is hooked up yet. --- .../iped-desktop-messages.properties | 3 + .../java/iped/app/ui/AIAssistantPanel.java | 260 ++++++++++++++++++ iped-app/src/main/java/iped/app/ui/App.java | 26 +- .../resources/iped/app/ui/ai-assistant.png | Bin 0 -> 4307 bytes 4 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java create mode 100644 iped-app/src/main/resources/iped/app/ui/ai-assistant.png diff --git a/iped-app/resources/localization/iped-desktop-messages.properties b/iped-app/resources/localization/iped-desktop-messages.properties index dfa6776be1..45e998b73d 100644 --- a/iped-app/resources/localization/iped-desktop-messages.properties +++ b/iped-app/resources/localization/iped-desktop-messages.properties @@ -20,6 +20,9 @@ App.RecursiveListing=Recursive Listing App.Search=Search App.SearchBoxTip=[Type or choose the search expression. Use [TAB] to autocomplete properties.] App.SearchLabel=Search: +AIAssistant.Tooltip=Launch AI Assistant +AIAssistant.Title=AI Assistant +AIAssistant.Send=Send App.Table=Table App.ToggleTimelineView=Toggle Table Timeline View App.Update=Update diff --git a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java new file mode 100644 index 0000000000..7eebf0b13c --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java @@ -0,0 +1,260 @@ +package iped.app.ui; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.GraphicsConfiguration; +import java.awt.GraphicsDevice; +import java.awt.GraphicsEnvironment; +import java.awt.Rectangle; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.text.SimpleDateFormat; +import java.util.Date; + +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.border.EmptyBorder; + +/** + * AI Assistant floating panel UI Layer for IPED. + * Strictly visual implementation for UI/UX testing (for now) + */ +public class AIAssistantPanel { + + private static final int HORIZONTAL_OFFSET = 30; + private static final int VERTICAL_OFFSET = 120; + private static final double HEIGHT_PERCENTAGE = 0.8; + private static final int PANEL_WIDTH = 550; + + private JDialog dialog; + private JTextArea chatArea; + private JTextArea inputArea; + private JButton sendButton; + private JLabel statusLabel; + private JProgressBar progressBar; + + private static AIAssistantPanel instance; + + /** + * Gets singleton instance + */ + public static AIAssistantPanel getInstance() { + if (instance == null) { + instance = new AIAssistantPanel(); + } + return instance; + } + + /** + * Private constructor - UI initialization only, no logic or LLM integration yet. + */ + private AIAssistantPanel() { + createUI(); + } + + /** + * Creates the floating window UI + */ + private void createUI() { + // Initialize Dialog - linked to the main App frame + dialog = new JDialog(App.get(), Messages.getString("AIAssistant.Title"), false); + dialog.setResizable(true); + + // Main panel + JPanel mainPanel = new JPanel(new BorderLayout(5, 5)); + mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); + + // Header with status + mainPanel.add(createHeaderPanel(), BorderLayout.NORTH); + + // Chat Display Area (Center) + chatArea = new JTextArea(); + chatArea.setEditable(false); + chatArea.setLineWrap(true); + chatArea.setWrapStyleWord(true); + chatArea.setFont(new Font("SansSerif", Font.PLAIN, 12)); + chatArea.setBackground(new Color(245, 245, 245)); + JScrollPane chatScroll = new JScrollPane(chatArea); + chatScroll.setPreferredSize(new Dimension(PANEL_WIDTH, 400)); + + // Quick Tasks Panel (Right Sidebar) + JPanel tasksPanel = createTasksPanel(); + + JPanel centerPanel = new JPanel(new BorderLayout(5, 5)); + centerPanel.add(chatScroll, BorderLayout.CENTER); + centerPanel.add(tasksPanel, BorderLayout.EAST); + mainPanel.add(centerPanel, BorderLayout.CENTER); + + // Input & Progress Section (Bottom) + mainPanel.add(createBottomPanel(), BorderLayout.SOUTH); + + dialog.getContentPane().add(mainPanel); + dialog.pack(); + positionDialog(); + + // Initial Mock Message + addMessage("System", "UI Layer Active. Still missing LLM integration."); + } + + /** + * Creates header panel with title and status + */ + private JPanel createHeaderPanel() { + JPanel headerPanel = new JPanel(new BorderLayout()); + + // Title + JLabel titleLabel = new JLabel(Messages.getString("AIAssistant.Title")); + titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14)); + + // Status indicator + statusLabel = new JLabel("● UI only, for now"); + statusLabel.setForeground(new Color(100, 100, 100)); + + // Assemble header + JPanel leftPanel = new JPanel(new BorderLayout()); + leftPanel.add(titleLabel, BorderLayout.NORTH); + leftPanel.add(statusLabel, BorderLayout.SOUTH); + + headerPanel.add(leftPanel, BorderLayout.WEST); + headerPanel.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Color.LIGHT_GRAY)); + + return headerPanel; + } + + /** + * Create quick tasks panel (right sidebar) + */ + private JPanel createTasksPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(BorderFactory.createTitledBorder("Quick Actions")); + + String[] tasks = {"Summarize", "Find Patterns", "Analyze Metadata"}; + for (String task : tasks) { + JButton btn = new JButton(task); + btn.setAlignmentX(Component.CENTER_ALIGNMENT); + btn.setMaximumSize(new Dimension(200, 30)); + // UI-only interaction: + btn.addActionListener(e -> addMessage("Action", "Requested: " + task)); + panel.add(btn); + panel.add(Box.createVerticalStrut(5)); + } + return panel; + } + + /** + * Creates input panel, located on the bottom, with text area and send button + */ + private JPanel createBottomPanel() { + JPanel bottomPanel = new JPanel(new BorderLayout(5, 5)); + + progressBar = new JProgressBar(); + progressBar.setIndeterminate(true); + progressBar.setVisible(false); + + inputArea = new JTextArea(6, 20); + inputArea.setLineWrap(true); + inputArea.setBorder(BorderFactory.createLineBorder(Color.GRAY)); + + // Key Listener for Enter Key + inputArea.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER && !e.isShiftDown()) { + e.consume(); + handleSendAction(); + } + } + }); + + sendButton = new JButton(Messages.getString("AIAssistant.Send")); + sendButton.addActionListener(e -> handleSendAction()); + + bottomPanel.add(progressBar, BorderLayout.NORTH); + bottomPanel.add(new JScrollPane(inputArea), BorderLayout.CENTER); + bottomPanel.add(sendButton, BorderLayout.EAST); + + return bottomPanel; + } + + private void positionDialog() { + GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getDefaultConfiguration(); + Rectangle screenBounds = gc.getBounds(); + + // Calculate dialogue height + int height = (int) (screenBounds.height * HEIGHT_PERCENTAGE); + dialog.setSize(PANEL_WIDTH + 150, height); + + // Position dialogue + int x = screenBounds.x + screenBounds.width - dialog.getWidth() - HORIZONTAL_OFFSET; + int y = screenBounds.y + VERTICAL_OFFSET; + + // Ensure dialogue fits on screen + if (y + dialog.getHeight() > screenBounds.y + screenBounds.height) { + y = screenBounds.y + screenBounds.height - dialog.getHeight(); + } + + dialog.setLocation(x, y); + } + + //----------------------------------------------------------------- + /** + * Everything downwards will need heavy editing for LLM integration + * Also, many methods are currently missing + * Don't forget to revise previous code when adding the integration + */ + //----------------------------------------------------------------- + + private void handleSendAction() { + String text = inputArea.getText().trim(); + if (!text.isEmpty()) { + addMessage("User", text); + inputArea.setText(""); + + // Simulation of "Processing" state for visual feedback + simulateProcessing(); + } + } + + private void addMessage(String sender, String message) { + String time = new SimpleDateFormat("HH:mm").format(new Date()); + chatArea.append(String.format("[%s] %s: %s\n\n", time, sender, message)); + chatArea.setCaretPosition(chatArea.getDocument().getLength()); + } + + private void simulateProcessing() { + setProcessing(true); + // Temporary timer to reset the UI after 1 second (purely for visual testing) + new javax.swing.Timer(1000, e -> setProcessing(false)).start(); + } + + private void setProcessing(boolean processing) { + progressBar.setVisible(processing); + sendButton.setEnabled(!processing); + inputArea.setEnabled(!processing); + dialog.setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) : Cursor.getDefaultCursor()); + } + + public void toggleVisibility() { + if (dialog.isVisible()) { + dialog.setVisible(false); + } else { + dialog.setVisible(true); + inputArea.requestFocusInWindow(); + } + } +} diff --git a/iped-app/src/main/java/iped/app/ui/App.java b/iped-app/src/main/java/iped/app/ui/App.java index 3e0691abe9..815e80ff05 100644 --- a/iped-app/src/main/java/iped/app/ui/App.java +++ b/iped-app/src/main/java/iped/app/ui/App.java @@ -210,7 +210,7 @@ public class App extends JFrame implements WindowListener, IMultiSearchResultPro public JDialog dialogBar; JProgressBar progressBar; JComboBox queryComboBox, filterComboBox; - JButton searchButton, optionsButton, updateCaseData, helpButton, exportToZip; + JButton searchButton, optionsButton, updateCaseData, helpButton, exportToZip, aiAssistantButton; JCheckBox checkBox, recursiveTreeList, filterDuplicates; JTable resultsTable; ResultTableListener resultTableListener; @@ -280,7 +280,7 @@ public class App extends JFrame implements WindowListener, IMultiSearchResultPro boolean isMultiCase; public JLabel status; - private static final String resPath = '/' + App.class.getPackageName().replace('.', '/') + '/'; + public static final String resPath = '/' + App.class.getPackageName().replace('.', '/') + '/'; final static String FILTRO_TODOS = Messages.getString("App.NoFilter"); //$NON-NLS-1$ final static String FILTRO_SELECTED = Messages.getString("App.Checked"); //$NON-NLS-1$ @@ -521,6 +521,19 @@ public void createGUI() { exportToZip = new JButton(Messages.getString("App.ExportZip")); //$NON-NLS-1$ checkBox = new JCheckBox("0"); //$NON-NLS-1$ + // AI assistant button: placeholder with no functionality yet, just to show the icon visually + aiAssistantButton = new JButton(); + aiAssistantButton.setIcon(IconUtil.getToolbarIcon("ai-assistant", resPath)); + + String aiTooltip = "AI Assistant"; + try { + aiTooltip = Messages.getString("AIAssistant.Tooltip"); + } catch (java.util.MissingResourceException e) { + LOGGER.warn("Missing tooltip resource key: AIAssistant.Tooltip", e); + } + aiAssistantButton.setToolTipText(aiTooltip); + aiAssistantButton.setFocusPainted(false); + filterComboBox = new JComboBox(); filterComboBox.setMaximumSize(new Dimension(100, 50)); filterComboBox.setMaximumRowCount(30); @@ -564,6 +577,7 @@ public void createGUI() { topPanel.add(exportToZip); exportToZip.setVisible(false); topPanel.add(checkBox); + topPanel.add(aiAssistantButton); topPanel.setBorder(BorderFactory.createEmptyBorder(1, 1, 1, 1)); resultsModel = new ResultTableModel(); @@ -799,6 +813,7 @@ protected void changed() { updateCaseData.addActionListener(appletListener); helpButton.addActionListener(appletListener); checkBox.addActionListener(appletListener); + aiAssistantButton.addActionListener(e -> AIAssistantPanel.getInstance().toggleVisibility()); resultsTable.getSelectionModel().addListSelectionListener(resultTableListener); resultsTable.addMouseListener(resultTableListener); resultsTable.addKeyListener(resultTableListener); @@ -891,6 +906,13 @@ public boolean dispatchKeyEvent(KeyEvent e) { } return true; } + if (e.isControlDown() && e.isShiftDown() && e.getKeyCode() == KeyEvent.VK_A) { + if (e.getID() == KeyEvent.KEY_RELEASED) { + // Shortcut to AI Assistant panel + AIAssistantPanel.getInstance().toggleVisibility(); + } + return true; + } } return false; } diff --git a/iped-app/src/main/resources/iped/app/ui/ai-assistant.png b/iped-app/src/main/resources/iped/app/ui/ai-assistant.png new file mode 100644 index 0000000000000000000000000000000000000000..62495b767a1c81f6f95def66a88f11d8a28bd4c2 GIT binary patch literal 4307 zcmV;^5G?PBP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!~g&e!~vBn4jTXf5NJt6K~#8Nj0$G4SNCXTB0oei~OV}4#9N9mRHQ*Cmcshs(a4(f;BO&GbJh!`&y)BwK-aBlxfeEL6L zIN^n%wI~R1yWMcYg&=D%tX8Z1e(nh`%k3pX?wT5SA$ScCs106;i3dtc?Q-Hqn2a>? z3?f_}Cz>^j#Ju@4Vd_6LnhNvy?y68L0V_w3n*PsYhu*acAqGkEK%kKq6BCVg?b@Pc zd`m<&jU?d(n2jb9oSIZ#8SVlMM6S^wz~yqm`faGxHmW%n#jrsv@O_dmk&H{OETV3Re*Y!UI=vX^1( zKZGq*iBeNRl?2p3x(8#&%#h4NkU=oIF=AvYmb~;l;#x(iHSTRl9uHny`X*Maco!Eh zT%{ZV;Su3j@y?%+pU}=m8KrVXLY0ot=rO-LZ84b*cIg@(>XwXWo?TdHgl7n%i2q!? z5F~l|{T~eG1`h0l`VH8-t-BaDDisL{ zt>JW&uRuP;{{06aNsgOxYY4DdEu-1RcF`mf9TP=2`ds|F3$_An+qRM26G1Q|JL`;6 zph81HR%TX=kyv8)vS~yFVw=S(1$7+VdUS?JzKmD$qUd~)nyWxVz|Tj1?ZU1sXNCla zATaRGsek&fnV33x4nA15jy$-VLr^ysJ^Kpg&s~hY|2U-P)Q;APtza~YBp12s*1U&5ytEAa_8wMC^kM1C%kkRlOY!!K|H3!h zcdEJi;EN`G&=BBsIaMEzf(~~2?~s=}Z0Hp7&w~+Si$#9^C491e6Ruz907f4&(z9R^ zLO~81E2`g+C~)$D0y_0ffrbF8$cqmIj<^gEZ^rqz4wuV~!$%ImY-ZCcKroo_`DdH) z%kh(H4gV2ZuO2BSwT;FzC;w5d+bf8qA%N;xd?#GEGS`JzQ$aheW8q#hR63x1szT760 z-Y~2ly7%Y^tCf{Zz>XceNTszVsaD7qN)cQ>^%DUBR{G^-8yX4~#*T&nkH>8+ll%4= zm^bGI+$eU?xD_#V>O`#nWDSy&+tW+U$jUm64_B=vlGMt6z-m<$l%8PF!2nwTMV`ts zX$Ww;C926xDSs8mi(b6{-pBF^W>j6}k1YNr(%lV0kab%a9$>%wz z^5g{!f8mkUcOE4FNP}%dT(J2Uxp$6M0`2G;JD%agU7#837rBC}7OEVF~hc$P*$kkpksWN$E@j#P*P&27kkmO zXIFIV-d-(HFni7*yRG z0kSw1h|?0_;Tb~a_vS$_{;=c?T)R>Xr`Lh*y*iTV>!6ls@yw!Gh>8uT*wcv2^i%lc z<1f^b>fx{}euL61|9i(`QLIN*cT^Yxl2A4b{C`1tto-kF@@!o{I2aQrK8nC#-X*Jy z`|1bdv9Tjb7z12xFFyNp6VgxAY!OOIc=t7 zii)noWHzGTfZpiYt5c23=FcyggQiU);g;Muemot!c6_InR1Mz-2)9j-BP8!4(G6AI zqSXL{haRqoFKdFPw010hVL8qG0Wi|!#`|@9e^5eJjt5R=WFtHC6pkD^hOCTJG)E|X z(y?O_0xjehlzOmn(_eA+%sDx~D!81A6`*KSd`w0AKnl>$8EqQ-;gEDtVl6I7@Nm+ErxcjpVE4vOx)IDg>+KKft-X-d^vK%S~{!ZuJ}|5OVt0XKIw^*#OX zyZ0h5uYmf+g9c$CXw#-O+TNdtq{Jk2Xx#xF5`3w>d~BcCo>CH$;@eWaR)~*}B|l%J zINyw)Ph{c8eO0gaD6TNY0W_)c2C2XB*5J@+gKF9E>!)_ktVJZcgvJd+vE=227&K@g z`xNpn^3AD-9?fUG&cNp!mU?LN(^ljc6=2iG%}5>6i!4Qma<2-3 z`bVCj=zd9ulTv;`M1FZWXlc|hI|>Vnkb5eh&IBZP>4--LbVb7m3&J9;h=>kCcyyqW zVgk{aQdpD?VbL};iV8$TOeh*Qv7trlMp*jlb7&eBK~FK^@Zlraw`Z^Nf^u-uWXd+c zLp{o&w!d&Xow8`k9o!{l{jMQ^D=8LJbvgZbNADgZsbLiI(%-02!_cH@W3@ydnze{R zpMELS!==bCxQH)4-+{|TTJO+FR)Cwto?UtOwl=t2%10EuD9TY}d7Ae&bSGkZV}<(Vd$CCl?atkP?U!) zTmFhGS9o_<0nI98(;Y;BgN8v_WLO&{UJ55!6shxyAy8W)`c~Qp;;1WCBfQZBN#Ozwh3I-VYC? zx!+7~w+(APU4iono#h8;S{~lG@nsHAO;qt>VIj_*tKKi?ASb|PzVpH}sA&No)W)^@0-^}QZR$$;pU)gefKw)k|J# zD>YYvhJcp$H$NB_pdo;wxwsy^ zy1^-x%42)yuHD$Yaf@17H!*h9WU?Mk8UQXdk5g8oedC{@A;6Q;cgC18qYxNCGcQd< zH%jeTvwFQ8?9@eED7=Wz$Xf9iFHFF|!Knxf3s*}ORGh(zi?0_C88&!wt z8oZcwG6!eQ=D=*U;#|Qw9Qx@9d3-@=+b&V9dGF%jfx~Foj2B~#aC)3*5*~r2e_V{> zF5N4ckBS<=kP70H42u>$<82WeM|RfS4E&Tf6OBG-?zkUvnhKo*9SqMw3xq>+?`!dE(`32Cpu8Bo7>R z2OJJ3u3fuMd6K+6#%m2cM|r5uiR0<`#}E6FoplPAE?t)M*_(CKUqquuc>eikAPz`n z;k#0*G?~>%03-HPru3ITuQcrb?r+o(vJOH3y~F^!_bP@D9Ewjjt|w$37L&y-1NL{xx0hwvU@SXh1e%S{eO6;RvC?Zx^F_U3pw z%(o_JF`E$*8iHmdaQmcoh=}0MrkFtyo_A$x{0v1G0gQvMy(+Ey=u-+tT^Q8A4>oT5 zMDAf9nnuLq+;90XllhS(H^OM#Pn|M>8p!{+ad*H`UPbffFLV=dbouMAto~rbB0CL% z;e!U@%dgkU`NhR0h;Gsb*RS7@hlrDG?*nb)G5v`tF!dX8OO4YBcJecoQ~#k`2d`ec z)}0qYOk`tovUBnK=U>4qe|!VM!Qm(=c2FF_|5hVl^vID&OH1+zKCSvOL*2Dm+Lk@rtL z0(n{F6S!S81CxDoNSBbDoP<>${MjdbC*_WNS9peQ0-P=<`6FzCc!yS$*XM0EE5?j^ z3{O2ZAD7xc$inl#m(+>H1l Date: Wed, 8 Apr 2026 13:25:47 -0300 Subject: [PATCH 033/143] feat(ai): improve multi-select context handling and UI - Added an `AIContextManager` with supporting events to manage selected context files in a centralized, thread-safe way. - Updated the `AIAssistantPanel` to show an "Added Context" section with a live list of files and a clear button. - Added an "Add to AI Context" option to the right-click menu (`MenuClass`). - Fixed multi-selection so the action correctly includes all checked or highlighted items, instead of just one. - Added batching when adding large numbers of files (e.g. Select All) to avoid freezing the UI. --- .../java/iped/app/ui/AIAssistantPanel.java | 295 +++++++++++++----- .../src/main/java/iped/app/ui/MenuClass.java | 81 ++++- .../java/iped/app/ui/ai/AIContextManager.java | 179 +++++++++++ .../iped/app/ui/ai/ContextChangeEvent.java | 46 +++ .../iped/app/ui/ai/ContextChangeListener.java | 21 ++ .../java/iped/app/ui/ai/ContextFileEntry.java | 79 +++++ 6 files changed, 617 insertions(+), 84 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/AIContextManager.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/ContextChangeEvent.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/ContextChangeListener.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java diff --git a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java index 7eebf0b13c..7b68758fc3 100644 --- a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java @@ -1,56 +1,70 @@ package iped.app.ui; -import java.awt.BorderLayout; -import java.awt.Color; -import java.awt.Component; -import java.awt.Cursor; -import java.awt.Dimension; -import java.awt.Font; -import java.awt.GraphicsConfiguration; -import java.awt.GraphicsDevice; -import java.awt.GraphicsEnvironment; -import java.awt.Rectangle; +import java.awt.*; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; -import java.awt.event.WindowAdapter; -import java.awt.event.WindowEvent; import java.text.SimpleDateFormat; import java.util.Date; +import java.util.List; -import javax.swing.BorderFactory; -import javax.swing.Box; -import javax.swing.BoxLayout; -import javax.swing.JButton; -import javax.swing.JDialog; -import javax.swing.JLabel; -import javax.swing.JPanel; -import javax.swing.JProgressBar; -import javax.swing.JScrollPane; -import javax.swing.JTextArea; +import javax.swing.*; import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; + +import iped.app.ui.ai.AIContextManager; +import iped.app.ui.ai.ContextChangeEvent; +import iped.app.ui.ai.ContextChangeListener; +import iped.app.ui.ai.ContextFileEntry; +import iped.data.IItem; /** - * AI Assistant floating panel UI Layer for IPED. - * Strictly visual implementation for UI/UX testing (for now) + * AI Assistant floating panel UI layer for IPED. + *

+ * This class implements the visual UI of the AI Assistant. It is a + * singleton and provides a floating panel containing: + *

    + *
  • Chat display and input areas
  • + *
  • AI context file management list
  • + *
  • Quick action buttons
  • + *
  • Status and progress indicators
  • + *
+ *

+ *

+ * Currently, this is strictly a UI layer for testing and does not include + * logic or LLM integration. + *

*/ public class AIAssistantPanel { - + + // UI positioning constants private static final int HORIZONTAL_OFFSET = 30; private static final int VERTICAL_OFFSET = 120; private static final double HEIGHT_PERCENTAGE = 0.8; private static final int PANEL_WIDTH = 550; - + + // Main UI components private JDialog dialog; private JTextArea chatArea; private JTextArea inputArea; private JButton sendButton; private JLabel statusLabel; private JProgressBar progressBar; - + + // Context-related UI components + private JPanel contextPanel; + private JList contextList; + private DefaultListModel contextListModel; + private JLabel contextEmptyLabel; + private JButton clearContextButton; + private TitledBorder contextBorder; + + // Singleton instance private static AIAssistantPanel instance; /** - * Gets singleton instance + * Returns the singleton instance of the AI Assistant Panel. + * + * @return singleton instance */ public static AIAssistantPanel getInstance() { if (instance == null) { @@ -60,28 +74,41 @@ public static AIAssistantPanel getInstance() { } /** - * Private constructor - UI initialization only, no logic or LLM integration yet. + * Private constructor: initializes UI components and registers context change listener. */ private AIAssistantPanel() { createUI(); + + // Listen for AI Context Changes to refresh context list + AIContextManager.getInstance().addContextChangeListener(new ContextChangeListener() { + @Override + public void contextChanged(ContextChangeEvent event) { + refreshContextUI(); + } + }); } /** - * Creates the floating window UI + * Creates the floating panel UI including chat, context, and quick tasks. */ private void createUI() { - // Initialize Dialog - linked to the main App frame - dialog = new JDialog(App.get(), Messages.getString("AIAssistant.Title"), false); + String title = "AI Assistant"; + try { title = Messages.getString("AIAssistant.Title"); } catch (Exception e) {} + + dialog = new JDialog(App.get(), title, false); dialog.setResizable(true); - - // Main panel + JPanel mainPanel = new JPanel(new BorderLayout(5, 5)); mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); - - // Header with status + + // Add header (title + status) mainPanel.add(createHeaderPanel(), BorderLayout.NORTH); - - // Chat Display Area (Center) + + // Center panel contains context list and chat + JPanel centerPanel = new JPanel(new BorderLayout(5, 5)); + centerPanel.add(createContextSection(), BorderLayout.NORTH); + + // Chat display area chatArea = new JTextArea(); chatArea.setEditable(false); chatArea.setLineWrap(true); @@ -90,45 +117,46 @@ private void createUI() { chatArea.setBackground(new Color(245, 245, 245)); JScrollPane chatScroll = new JScrollPane(chatArea); chatScroll.setPreferredSize(new Dimension(PANEL_WIDTH, 400)); - - // Quick Tasks Panel (Right Sidebar) - JPanel tasksPanel = createTasksPanel(); - - JPanel centerPanel = new JPanel(new BorderLayout(5, 5)); + centerPanel.add(chatScroll, BorderLayout.CENTER); + + // Quick action buttons + JPanel tasksPanel = createTasksPanel(); centerPanel.add(tasksPanel, BorderLayout.EAST); + mainPanel.add(centerPanel, BorderLayout.CENTER); - - // Input & Progress Section (Bottom) + + // Input area and send button at the bottom mainPanel.add(createBottomPanel(), BorderLayout.SOUTH); - + dialog.getContentPane().add(mainPanel); dialog.pack(); positionDialog(); - - // Initial Mock Message - addMessage("System", "UI Layer Active. Still missing LLM integration."); + + // Initial system message + addMessage("System", "UI Layer Active. Still missing LLM integration. Right click files to add them to Context."); } /** - * Creates header panel with title and status + * Creates the header panel with title and status label. + * + * @return header panel */ private JPanel createHeaderPanel() { JPanel headerPanel = new JPanel(new BorderLayout()); - // Title - JLabel titleLabel = new JLabel(Messages.getString("AIAssistant.Title")); + String titleText = "AI Assistant"; + try { titleText = Messages.getString("AIAssistant.Title"); } catch (Exception e) {} + JLabel titleLabel = new JLabel(titleText); titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14)); - - // Status indicator + statusLabel = new JLabel("● UI only, for now"); statusLabel.setForeground(new Color(100, 100, 100)); - - // Assemble header + JPanel leftPanel = new JPanel(new BorderLayout()); leftPanel.add(titleLabel, BorderLayout.NORTH); leftPanel.add(statusLabel, BorderLayout.SOUTH); - + headerPanel.add(leftPanel, BorderLayout.WEST); headerPanel.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Color.LIGHT_GRAY)); @@ -136,7 +164,90 @@ private JPanel createHeaderPanel() { } /** - * Create quick tasks panel (right sidebar) + * Creates the AI context section showing added files and clear button. + * + * @return context panel + */ + private JPanel createContextSection() { + contextListModel = new DefaultListModel<>(); + contextList = new JList<>(contextListModel); + contextList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + contextList.setVisibleRowCount(5); + contextList.setBackground(new Color(255, 255, 240)); + + // Custom cell renderer to show file label and tooltip + contextList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { + JLabel label = (JLabel) new DefaultListCellRenderer() + .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof ContextFileEntry) { + ContextFileEntry entry = (ContextFileEntry) value; + label.setText(entry.getDisplayLabel()); + label.setToolTipText(entry.getFullPath()); + } + return label; + }); + + JScrollPane contextScroll = new JScrollPane(contextList); + contextScroll.setPreferredSize(new Dimension(PANEL_WIDTH - 10, 80)); + + contextEmptyLabel = new JLabel("No files added to context."); + contextEmptyLabel.setForeground(Color.GRAY); + contextEmptyLabel.setFont(new Font("SansSerif", Font.ITALIC, 11)); + contextEmptyLabel.setHorizontalAlignment(SwingConstants.CENTER); + + JPanel listContainer = new JPanel(new BorderLayout()); + listContainer.add(contextScroll, BorderLayout.CENTER); + listContainer.add(contextEmptyLabel, BorderLayout.NORTH); + + // Clear button to remove all context files + clearContextButton = new JButton("Clear"); + clearContextButton.setMargin(new Insets(0, 5, 0, 5)); + clearContextButton.setEnabled(false); + clearContextButton.addActionListener(e -> AIContextManager.getInstance().clearContext()); + + JPanel actionPanel = new JPanel(new BorderLayout()); + actionPanel.add(clearContextButton, BorderLayout.NORTH); + + contextPanel = new JPanel(new BorderLayout(5, 5)); + contextBorder = BorderFactory.createTitledBorder("Added Context (0 files)"); + contextPanel.setBorder(contextBorder); + + contextPanel.add(listContainer, BorderLayout.CENTER); + contextPanel.add(actionPanel, BorderLayout.EAST); + + refreshContextUI(); + + return contextPanel; + } + + /** + * Updates the context list UI based on current AI context files. + */ + private void refreshContextUI() { + List files = AIContextManager.getInstance().getContextFiles(); + contextListModel.clear(); + + if (files.isEmpty()) { + contextEmptyLabel.setVisible(true); + contextList.setVisible(false); + clearContextButton.setEnabled(false); + } else { + contextEmptyLabel.setVisible(false); + contextList.setVisible(true); + clearContextButton.setEnabled(true); + for (IItem file : files) { + contextListModel.addElement(new ContextFileEntry(file)); + } + } + + contextBorder.setTitle("Added Context (" + files.size() + " files)"); + contextPanel.repaint(); + } + + /** + * Creates the panel containing quick action buttons. + * + * @return tasks panel */ private JPanel createTasksPanel() { JPanel panel = new JPanel(); @@ -148,7 +259,6 @@ private JPanel createTasksPanel() { JButton btn = new JButton(task); btn.setAlignmentX(Component.CENTER_ALIGNMENT); btn.setMaximumSize(new Dimension(200, 30)); - // UI-only interaction: btn.addActionListener(e -> addMessage("Action", "Requested: " + task)); panel.add(btn); panel.add(Box.createVerticalStrut(5)); @@ -157,20 +267,20 @@ private JPanel createTasksPanel() { } /** - * Creates input panel, located on the bottom, with text area and send button + * Creates the bottom panel containing input area, send button, and progress bar. + * + * @return bottom panel */ private JPanel createBottomPanel() { JPanel bottomPanel = new JPanel(new BorderLayout(5, 5)); - + progressBar = new JProgressBar(); progressBar.setIndeterminate(true); progressBar.setVisible(false); - + inputArea = new JTextArea(6, 20); inputArea.setLineWrap(true); inputArea.setBorder(BorderFactory.createLineBorder(Color.GRAY)); - - // Key Listener for Enter Key inputArea.addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { @@ -181,29 +291,33 @@ public void keyPressed(KeyEvent e) { } }); - sendButton = new JButton(Messages.getString("AIAssistant.Send")); + String sendText = "Send"; + try { sendText = Messages.getString("AIAssistant.Send"); } catch (Exception e) {} + + sendButton = new JButton(sendText); sendButton.addActionListener(e -> handleSendAction()); bottomPanel.add(progressBar, BorderLayout.NORTH); bottomPanel.add(new JScrollPane(inputArea), BorderLayout.CENTER); bottomPanel.add(sendButton, BorderLayout.EAST); - + return bottomPanel; } + /** + * Positions the floating dialog on the screen with offsets. + */ private void positionDialog() { - GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getDefaultConfiguration(); + GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment() + .getDefaultScreenDevice().getDefaultConfiguration(); Rectangle screenBounds = gc.getBounds(); - - // Calculate dialogue height + int height = (int) (screenBounds.height * HEIGHT_PERCENTAGE); dialog.setSize(PANEL_WIDTH + 150, height); - // Position dialogue int x = screenBounds.x + screenBounds.width - dialog.getWidth() - HORIZONTAL_OFFSET; int y = screenBounds.y + VERTICAL_OFFSET; - // Ensure dialogue fits on screen if (y + dialog.getHeight() > screenBounds.y + screenBounds.height) { y = screenBounds.y + screenBounds.height - dialog.getHeight(); } @@ -211,37 +325,43 @@ private void positionDialog() { dialog.setLocation(x, y); } - //----------------------------------------------------------------- /** - * Everything downwards will need heavy editing for LLM integration - * Also, many methods are currently missing - * Don't forget to revise previous code when adding the integration + * Handles sending the user's input message. */ - //----------------------------------------------------------------- - private void handleSendAction() { String text = inputArea.getText().trim(); if (!text.isEmpty()) { addMessage("User", text); inputArea.setText(""); - - // Simulation of "Processing" state for visual feedback simulateProcessing(); } } + /** + * Appends a message to the chat area with timestamp. + * + * @param sender name of the sender (e.g., User, System) + * @param message message text + */ private void addMessage(String sender, String message) { String time = new SimpleDateFormat("HH:mm").format(new Date()); chatArea.append(String.format("[%s] %s: %s\n\n", time, sender, message)); chatArea.setCaretPosition(chatArea.getDocument().getLength()); } + /** + * Simulates AI processing by showing the progress bar briefly. + */ private void simulateProcessing() { setProcessing(true); - // Temporary timer to reset the UI after 1 second (purely for visual testing) new javax.swing.Timer(1000, e -> setProcessing(false)).start(); } + /** + * Sets the UI into a processing state. + * + * @param processing true if processing, false otherwise + */ private void setProcessing(boolean processing) { progressBar.setVisible(processing); sendButton.setEnabled(!processing); @@ -249,6 +369,9 @@ private void setProcessing(boolean processing) { dialog.setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) : Cursor.getDefaultCursor()); } + /** + * Toggles the visibility of the AI Assistant panel. + */ public void toggleVisibility() { if (dialog.isVisible()) { dialog.setVisible(false); @@ -257,4 +380,14 @@ public void toggleVisibility() { inputArea.requestFocusInWindow(); } } -} + + /** + * Shows the panel if hidden, and requests input focus. + */ + public void showPanel() { + if (!dialog.isVisible()) { + dialog.setVisible(true); + } + inputArea.requestFocusInWindow(); + } +} \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/MenuClass.java b/iped-app/src/main/java/iped/app/ui/MenuClass.java index 4cea5aa811..2c6e6669c5 100644 --- a/iped-app/src/main/java/iped/app/ui/MenuClass.java +++ b/iped-app/src/main/java/iped/app/ui/MenuClass.java @@ -1,7 +1,6 @@ /* * Copyright 2012-2014, Luis Filipe da Cruz Nassif - * - * This file is part of Indexador e Processador de Evidências Digitais (IPED). + * * This file is part of Indexador e Processador de Evidências Digitais (IPED). * * IPED is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,6 +20,7 @@ import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.KeyEvent; +import java.util.ArrayList; import java.util.List; import javax.swing.JComponent; @@ -28,8 +28,11 @@ import javax.swing.JMenuItem; import javax.swing.JPopupMenu; import javax.swing.JRadioButtonMenuItem; +import javax.swing.JTable; import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import iped.app.ui.ai.AIContextManager; import iped.app.ui.themes.Theme; import iped.app.ui.themes.ThemeManager; import iped.data.IItem; @@ -48,7 +51,7 @@ public class MenuClass extends JPopupMenu { JMenuItem exportHighlighted, copyHighlighted, checkHighlighted, uncheckHighlighted, readHighlighted, unreadHighlighted, exportChecked, copyChecked, saveBookmarks, loadBookmarks, checkHighlightedAndSubItems, uncheckHighlightedAndSubItems, checkHighlightedAndParent, uncheckHighlightedAndParent, checkHighlightedAndReferences, uncheckHighlightedAndReferences, checkHighlightedAndReferencedBy, uncheckHighlightedAndReferencedBy, changeGalleryColCount, defaultLayout, changeLayout, previewScreenshot, manageBookmarks, clearSearchHistory, importKeywords, navigateToParent, exportTerms, manageFilters, manageColumns, exportCheckedToZip, exportCheckedTreeToZip, - exportTree, exportTreeChecked, similarDocs, openViewfile, createReport, resetColLayout, lastColLayout, saveColLayout, addToGraph, navigateToParentChat, pinFirstColumns, similarImagesCurrent, similarImagesExternal, + exportTree, exportTreeChecked, similarDocs, openViewfile, createReport, resetColLayout, lastColLayout, saveColLayout, addToGraph, addToAIContext, navigateToParentChat, pinFirstColumns, similarImagesCurrent, similarImagesExternal, similarFacesCurrent, similarFacesExternal, toggleTimelineView, uiZoom, catIconSize, savePanelsLayout, loadPanelsLayout; MenuListener menuListener = new MenuListener(this); @@ -330,6 +333,34 @@ public void actionPerformed(ActionEvent e) { addToGraph.setEnabled(App.get().appGraphAnalytics.isEnabled() && item != null && item.getMetadata().get(ExtraProperties.COMMUNICATION_FROM) != null && item.getMetadata().get(ExtraProperties.COMMUNICATION_TO) != null); addToGraph.addActionListener(menuListener); this.add(addToGraph); + + // AI Context + String aiContextText = "Add to AI Context"; + try { aiContextText = Messages.getString("MenuClass.AddToAIContext"); } catch(Exception ignored) {} + + addToAIContext = new JMenuItem(aiContextText); + try { + addToAIContext.setIcon(iped.utils.IconUtil.getToolbarIcon("ai-assistant", App.resPath)); + } catch (Exception ignored) {} + + addToAIContext.setEnabled(item != null); + addToAIContext.addActionListener(e -> { + // Run on background thread to prevent UI freezing if many files are selected + new Thread(() -> { + List itemsToAdd = getSelectedOrCheckedItems(item); + + if (!itemsToAdd.isEmpty()) { + // Add all items in a single batch operation + AIContextManager.getInstance().addContextFiles(itemsToAdd); + + // UI updates must happen on the Event Dispatch Thread + SwingUtilities.invokeLater(() -> { + AIAssistantPanel.getInstance().showPanel(); + }); + } + }).start(); + }); + this.add(addToAIContext); this.addSeparator(); @@ -337,6 +368,50 @@ public void actionPerformed(ActionEvent e) { createReport.addActionListener(menuListener); this.add(createReport); + } + + // Helper Methods moved outside the constructor + private List getSelectedOrCheckedItems(IItem clickedItem) { + List selectedItems = new ArrayList<>(); + JTable table = App.get().getResultsTable(); + + if (table != null && !isTreeMenu) { + // Check for "Checked" items + int checkCol = table.convertColumnIndexToView(1); + for (int i = 0; i < table.getRowCount(); i++) { + Boolean isChecked = (Boolean) table.getValueAt(i, checkCol); + if (Boolean.TRUE.equals(isChecked)) { + selectedItems.add(resolveItemFromRow(table, i)); + } + } + + // Fallback: Check for standard highlighted rows if nothing is checked + if (selectedItems.isEmpty()) { + int[] selectedRows = table.getSelectedRows(); + if (selectedRows.length > 1) { // More than just the clicked row + for (int row : selectedRows) { + selectedItems.add(resolveItemFromRow(table, row)); + } + } + } + } + + // Fallback: Just use the single right-clicked item + if (selectedItems.isEmpty() && clickedItem != null) { + selectedItems.add(clickedItem); + } + + return selectedItems; + } + + private IItem resolveItemFromRow(JTable table, int viewRow) { + // Converts the visual row index to the underlying model index, + // then fetches the item ID from IPED's search result model. + int modelIdx = table.convertRowIndexToModel(viewRow); + iped.data.IItemId itemId = App.get().getResults().getItem(modelIdx); + + // Fetch the actual IItem using IPED's appCase directly + return App.get().appCase.getItemByItemId(itemId); } public void addExportTreeMenuItems(JComponent menu) { diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIContextManager.java b/iped-app/src/main/java/iped/app/ui/ai/AIContextManager.java new file mode 100644 index 0000000000..2e9e0ae5e9 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/AIContextManager.java @@ -0,0 +1,179 @@ +package iped.app.ui.ai; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import javax.swing.SwingUtilities; +import javax.swing.event.EventListenerList; + +import iped.data.IItem; + +/** + * Singleton manager responsible for maintaining the AI context file list. + *

+ * This class provides thread-safe operations for adding, removing, and clearing + * context files used by the AI. It also implements an event-listener mechanism + * to notify interested components whenever the context changes. + *

+ * + *

+ * Internally, it uses a {@link CopyOnWriteArrayList} to ensure safe concurrent access + * without explicit synchronization for read-heavy operations. + *

+ * + *

+ * All UI-related notifications are dispatched on the Swing Event Dispatch Thread (EDT). + *

+ */ +public class AIContextManager { + + /** Singleton instance */ + private static AIContextManager instance; + + /** Thread-safe list holding context files */ + private final List contextFiles; + + /** Listener list for context change events */ + private final EventListenerList listeners; + + /** + * Private constructor to enforce singleton pattern. + */ + private AIContextManager() { + this.contextFiles = new CopyOnWriteArrayList<>(); + this.listeners = new EventListenerList(); + } + + /** + * Returns the singleton instance of the manager. + * + * @return the single {@code AIContextManager} instance + */ + public static synchronized AIContextManager getInstance() { + if (instance == null) { + instance = new AIContextManager(); + } + return instance; + } + + /** + * Adds a file to the context if it is not already present. + * + * @param item the item to add; ignored if {@code null} + */ + public void addContextFile(IItem item) { + if (item == null) return; + + boolean alreadyExists = contextFiles.stream() + .anyMatch(existing -> existing.getId() == item.getId()); + + if (!alreadyExists) { + contextFiles.add(item); + fireContextChanged(ContextChangeEvent.FILE_ADDED); + } + } + + /** + * Removes a file from the context based on its ID. + * + * @param item the item to remove + */ + public void removeContextFile(IItem item) { + if (contextFiles.removeIf(existing -> existing.getId() == item.getId())) { + fireContextChanged(ContextChangeEvent.FILE_REMOVED); + } + } + + /** + * Clears all context files. + */ + public void clearContext() { + if (!contextFiles.isEmpty()) { + contextFiles.clear(); + fireContextChanged(ContextChangeEvent.CLEARED); + } + } + + /** + * Returns a copy of the current context file list. + * + * @return a new list containing all context files + */ + public List getContextFiles() { + return new ArrayList<>(contextFiles); + } + + /** + * Registers a listener to receive context change events. + * + * @param listener the listener to add + */ + public void addContextChangeListener(ContextChangeListener listener) { + listeners.add(ContextChangeListener.class, listener); + } + + /** + * Removes a previously registered listener. + * + * @param listener the listener to remove + */ + public void removeContextChangeListener(ContextChangeListener listener) { + listeners.remove(ContextChangeListener.class, listener); + } + + /** + * Notifies all registered listeners about a context change. + *

+ * Ensures that notifications are dispatched on the Swing EDT. + *

+ * + * @param changeType the type of change that occurred + */ + private void fireContextChanged(String changeType) { + ContextChangeEvent event = new ContextChangeEvent(this, changeType); + Runnable notification = () -> { + for (ContextChangeListener listener : listeners.getListeners(ContextChangeListener.class)) { + listener.contextChanged(event); + } + }; + + if (SwingUtilities.isEventDispatchThread()) { + notification.run(); + } else { + SwingUtilities.invokeLater(notification); + } + } + + /** + * Batch adds multiple files to the context. + *

+ * This method avoids UI thread starvation by processing all additions first + * and firing a single change event at the end. + *

+ * + * @param items list of items to add; ignored if {@code null} or empty + */ + public void addContextFiles(List items) { + if (items == null || items.isEmpty()) return; + + boolean addedAny = false; + + for (IItem item : items) { + if (item == null) continue; + + // Deduplication check + boolean alreadyExists = contextFiles.stream() + .anyMatch(existing -> existing.getId() == item.getId()); + + if (!alreadyExists) { + contextFiles.add(item); + addedAny = true; + } + } + + // Fire a single event after the entire batch is processed + if (addedAny) { + fireContextChanged(ContextChangeEvent.FILE_ADDED); + } + } +} \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/ContextChangeEvent.java b/iped-app/src/main/java/iped/app/ui/ai/ContextChangeEvent.java new file mode 100644 index 0000000000..89dd75b8c1 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/ContextChangeEvent.java @@ -0,0 +1,46 @@ +package iped.app.ui.ai; + +import java.util.EventObject; + +/** + * Event representing a change in the AI context. + *

+ * This event is fired whenever a context file is added, removed, or when + * the entire context is cleared. It provides the type of change via + * {@link #getChangeType()}. + *

+ */ +public class ContextChangeEvent extends EventObject { + + /** Change type constant for adding a file */ + public static final String FILE_ADDED = "fileAdded"; + + /** Change type constant for removing a file */ + public static final String FILE_REMOVED = "fileRemoved"; + + /** Change type constant for clearing all files */ + public static final String CLEARED = "cleared"; + + /** The type of change that occurred */ + private final String changeType; + + /** + * Constructs a new {@code ContextChangeEvent}. + * + * @param source the object that originated the event + * @param changeType the type of change (one of the static constants) + */ + public ContextChangeEvent(Object source, String changeType) { + super(source); + this.changeType = changeType; + } + + /** + * Returns the type of change that occurred. + * + * @return the change type string + */ + public String getChangeType() { + return changeType; + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/ContextChangeListener.java b/iped-app/src/main/java/iped/app/ui/ai/ContextChangeListener.java new file mode 100644 index 0000000000..6dcab386b9 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/ContextChangeListener.java @@ -0,0 +1,21 @@ +package iped.app.ui.ai; + +import java.util.EventListener; + +/** + * Listener interface for receiving AI context change events. + *

+ * Implementations of this interface can be registered with + * {@link AIContextManager} to receive notifications whenever the + * context files are added, removed, or cleared. + *

+ */ +public interface ContextChangeListener extends EventListener { + + /** + * Invoked when the AI context changes. + * + * @param event the context change event containing the change type + */ + void contextChanged(ContextChangeEvent event); +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java b/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java new file mode 100644 index 0000000000..87e9fa46bc --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java @@ -0,0 +1,79 @@ +package iped.app.ui.ai; + +import iped.data.IItem; + +/** + * Wrapper class for {@link IItem} to represent a file entry in the AI context. + *

+ * Provides convenient methods for displaying file names, paths, and labels + * in the UI, as well as proper {@code equals} and {@code hashCode} implementations + * based on the unique item ID. + *

+ */ +public class ContextFileEntry { + + /** The underlying file item */ + private final IItem item; + + /** + * Constructs a new {@code ContextFileEntry} wrapping the given {@link IItem}. + * + * @param item the item to wrap + */ + public ContextFileEntry(IItem item) { + this.item = item; + } + + /** + * Returns the underlying {@link IItem}. + * + * @return the wrapped item + */ + public IItem getItem() { + return item; + } + + /** + * Returns the displayable file name, or "Unknown File" if {@code null}. + * + * @return file name + */ + public String getFileName() { + return item.getName() != null ? item.getName() : "Unknown File"; + } + + /** + * Returns the full file path, or an empty string if {@code null}. + * + * @return full path + */ + public String getFullPath() { + return item.getPath() != null ? item.getPath() : ""; + } + + /** + * Returns the label used for display in UI lists. + * + * @return display label + */ + public String getDisplayLabel() { + return getFileName(); + } + + @Override + public String toString() { + return getDisplayLabel(); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ContextFileEntry)) return false; + return this.item.getId() == ((ContextFileEntry) o).item.getId(); + } + + @Override + public int hashCode() { + // Use Integer wrapper to get the hashcode of a primitive int + return Integer.hashCode(item.getId()); + } +} \ No newline at end of file From 153c22cad6b49288540cb9a8c9ade94b79111cd9 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 9 Apr 2026 15:48:52 -0300 Subject: [PATCH 034/143] feat(llm integration): add AI backend integration contract --- .../app/ui/ai/backend/AIBackendClient.java | 37 +++++++++++++++++++ .../app/ui/ai/backend/AIBackendConfig.java | 37 +++++++++++++++++++ .../app/ui/ai/backend/AIBackendException.java | 37 +++++++++++++++++++ .../app/ui/ai/backend/AIBackendService.java | 25 +++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendException.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java new file mode 100644 index 0000000000..1bb0695499 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -0,0 +1,37 @@ +package iped.app.ui.ai.backend; + +import java.util.function.Consumer; + +/** + * The concrete implementation of the {@link AIBackendService} that handles HTTP + * communication with the AI backend. + *

+ * This class is responsible for taking the application's requests, formatting them + * into the appropriate network payloads, executing the HTTP calls using + * the provided {@link AIBackendConfig}, and parsing the responses back to the application. + *

+ */ +public class AIBackendClient implements AIBackendService { + + private final AIBackendConfig config; + + /** + * Constructs a new AIBackendClient with the specified configuration. + * @param config The AI backend configuration (must not be null). + */ + public AIBackendClient(AIBackendConfig config) { + this.config = config; + } + + @Override + public String initChat(String chatHtml) throws AIBackendException { + // TODO: Implement later (step 4) + throw new UnsupportedOperationException("Backend HTTP calls are not yet implemented."); + } + + @Override + public void streamChatResponse(String chatHash, String question, Consumer eventHandler) throws AIBackendException { + // TODO: Implement later (step 4) + throw new UnsupportedOperationException("Backend HTTP calls are not yet implemented."); + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java new file mode 100644 index 0000000000..1fe29d4167 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java @@ -0,0 +1,37 @@ +package iped.app.ui.ai.backend; + +/** + * An immutable configuration object that holds the necessary connection details + * for communicating with the AI Large Language Model (LLM) backend. + *

+ * This class encapsulates the endpoint URL and authentication credentials. + * By passing this object to the backend services rather than individual string + * arguments, the system remains flexible and easier to maintain. + *

+ */ +public class AIBackendConfig { + + // The root URL of the AI API endpoint + private final String baseUrl; + + /** * The authentication key required to access the AI service. + * This may be an empty string or null if connecting to an unsecured local model. + */ + private final String apiKey; + + public AIBackendConfig(String baseUrl, String apiKey) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + } + + public String getBaseUrl() { + return baseUrl; + } + + public String getApiKey() { + return apiKey; + } + + // Note: Future integration point for loading these values directly from + // IPED's configuration property files (e.g., using iped.engine.config.ConfigurationManager) +} \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendException.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendException.java new file mode 100644 index 0000000000..58f7b321c3 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendException.java @@ -0,0 +1,37 @@ +package iped.app.ui.ai.backend; + +/** + * A custom checked exception representing errors that occur within the AI backend layer. + *

+ * This exception serves as a generic wrapper for various lower-level failures that might + * happen during AI processing, such as network timeouts when reaching an LLM API, + * JSON parsing errors, or authentication failures. By catching low-level exceptions + * and wrapping them in an {@code AIBackendException}, the UI and presentation layers + * can handle AI-specific errors gracefully without needing to know the implementation + * details of the backend. + *

+ */ +public class AIBackendException extends Exception { + + /** + * Constructs a new AIBackendException with the specified detail message. + * * @param message The detail message explaining the specific failure (e.g., "Connection to LLM timed out"). + */ + public AIBackendException(String message) { + super(message); + } + + /** + * Constructs a new AIBackendException with the specified detail message and root cause. + *

+ * This constructor is used for "Exception Chaining," allowing you to preserve the original + * stack trace of the underlying error (like an IOException) while presenting a clean + * AIBackendException to the rest of the application. + *

+ * @param message The detail message explaining the specific failure. + * @param cause The underlying cause of the failure (can be null). + */ + public AIBackendException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java new file mode 100644 index 0000000000..e5f7a1440d --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java @@ -0,0 +1,25 @@ +package iped.app.ui.ai.backend; + +import java.util.function.Consumer; + +public interface AIBackendService { + + /** + * Sends the extracted HTML content to the backend to initialize the chat. + * @param chatHtml The raw WhatsApp HTML string. + * @return The MD5 chat_hash returned by the server. + * @throws AIBackendException if the server rejects the HTML or is unreachable. + */ + String initChat(String chatHtml) throws AIBackendException; + + /** + * Queries the LLM regarding a previously initialized chat. + * @param chatHash The hash returned from initChat. + * @param question The user's question. + * @param eventHandler A callback that receives streamed tokens/status updates from the LLM. + * Allows the implementing class to push tokens to the UI as soon as they + * arrive over the network. + * @throws AIBackendException if the connection fails. + */ + void streamChatResponse(String chatHash, String question, Consumer eventHandler) throws AIBackendException; +} \ No newline at end of file From 7fc4da9b55778670d3c6072c67eb3f0a6d5e71c0 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Fri, 10 Apr 2026 13:33:57 -0300 Subject: [PATCH 035/143] feat (llm integration) add WhatsApp chat content extraction and payload model --- .../app/ui/ai/AIWhatsappChatExtractor.java | 75 +++++++++++++++++++ .../iped/app/ui/ai/backend/AIChatRequest.java | 31 ++++++++ 2 files changed, 106 insertions(+) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIChatRequest.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java b/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java new file mode 100644 index 0000000000..2c5fd7fcd2 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java @@ -0,0 +1,75 @@ +package iped.app.ui.ai; + +import iped.data.IItem; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * A utility service responsible for validating and extracting text content + * from IPED digital evidence items, specifically targeting WhatsApp HTML exports. + *

+ * This class acts as a boundary layer between IPED's raw file storage and the + * AI Backend payload builders. It ensures that only potentially valid text data + * is loaded into memory and sent across the network. + *

+ */ +public class AIWhatsappChatExtractor { + + /** + *

+ * Basic validation to check if the item is an HTML file. + * The SARD backend will do the deep WhatsApp signature validation, + * but we want to prevent sending obviously wrong files (like MP4s). + *

+ * @param item The IPED evidence item to inspect. + * @return true if the file extension or MIME type indicates HTML content; false otherwise. + */ + public boolean isPotentiallyValidChat(IItem item) { + if (item == null) return false; + + // Check by file extension + String ext = item.getExt(); + if (ext != null && (ext.equalsIgnoreCase("html") || ext.equalsIgnoreCase("htm"))) { + return true; + } + + // Fallback check by IPED's recognized Media Type + String mediaType = item.getMediaType() != null ? item.getMediaType().getType() : ""; + return mediaType.contains("text/html"); + } + + /** + * Extracts the raw file content from the IItem into a UTF-8 encoded String. + * @param item The IPED evidence item containing the chat log. + * @return The complete, raw HTML string. + * @throws IllegalArgumentException if the item fails basic HTML validation. + * @throws IllegalStateException if the underlying file stream cannot be opened. + * @throws Exception if reading the stream fails due to an I/O error. + */ + public String extractHtml(IItem item) throws Exception { + if (!isPotentiallyValidChat(item)) { + throw new IllegalArgumentException("Selected file does not appear to be an HTML chat export."); + } + + // Try-with-resources ensures streams are closed automatically, preventing memory/file handle leaks. + try (InputStream is = item.getBufferedInputStream(); + ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + + if (is == null) { + throw new IllegalStateException("Could not open input stream for item ID: " + item.getId()); + } + + int nRead; + byte[] data = new byte[16384]; // 16KB chunks + + while ((nRead = is.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + + // WhatsApp HTML exports heavily utilize emojis and international characters. + // Forcing UTF-8 is strictly required here. + return new String(buffer.toByteArray(), StandardCharsets.UTF_8); + } + } +} \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIChatRequest.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIChatRequest.java new file mode 100644 index 0000000000..da2c851b13 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIChatRequest.java @@ -0,0 +1,31 @@ +package iped.app.ui.ai.backend; + +/** + * A Data Transfer Object (DTO) representing the initial payload sent to the backend. + *

+ * This class encapsulates the context data that needs to be uploaded to the LLM to start + * a new stateful chat session. + *

+ */ +public class AIChatRequest { + + /** + * The raw text or HTML content to be analyzed by the AI. + * Note: The snake_case naming convention is used intentionally here to map + * directly to the Python backend's expected JSON schema without requiring + * additional serialization annotations. + */ + private final String chat_content; + + public AIChatRequest(String chat_content) { + this.chat_content = chat_content; + } + + /** + * Retrieves the chat content payload. + * @return The string content. + */ + public String getChatContent() { + return chat_content; + } +} From 67b55955c49a4aca2773884101b4f139efd3ac03 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 13 Apr 2026 14:42:21 -0300 Subject: [PATCH 036/143] fix(html extraction): fix isPotentiallyValidChat to recognize virtual files extracted from databases (which lack may lack extensions) --- .../app/ui/ai/AIWhatsappChatExtractor.java | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java b/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java index 2c5fd7fcd2..fddc927711 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java @@ -19,24 +19,35 @@ public class AIWhatsappChatExtractor { /** *

* Basic validation to check if the item is an HTML file. - * The SARD backend will do the deep WhatsApp signature validation, - * but we want to prevent sending obviously wrong files (like MP4s). + * Handles standard files and virtual files extracted from databases (which may lack extensions). *

* @param item The IPED evidence item to inspect. - * @return true if the file extension or MIME type indicates HTML content; false otherwise. + * @return true if the file indicates HTML or WhatsApp chat content; false otherwise. */ public boolean isPotentiallyValidChat(IItem item) { if (item == null) return false; - // Check by file extension + // Check by explicit file extension String ext = item.getExt(); if (ext != null && (ext.equalsIgnoreCase("html") || ext.equalsIgnoreCase("htm"))) { return true; } - // Fallback check by IPED's recognized Media Type - String mediaType = item.getMediaType() != null ? item.getMediaType().getType() : ""; - return mediaType.contains("text/html"); + // Check by IPED's Media Type (MIME type string, e.g., "text/html") + if (item.getMediaType() != null) { + String mimeType = item.getMediaType().getType().toLowerCase(); + if (mimeType.contains("html")) { + return true; + } + } + + // Check by Virtual Filename (Fallback for .db extractions) + String name = item.getName(); + if (name != null && name.toLowerCase().startsWith("whatsapp chat")) { + return true; + } + + return false; } /** From bb14cd264d09b59732c91b3b9c5f1164e10d8c22 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 13 Apr 2026 14:50:14 -0300 Subject: [PATCH 037/143] feat(llm integration): wire UI send flow to a mock backend service to test logical operation --- .../java/iped/app/ui/AIAssistantPanel.java | 142 +++++++----------- .../iped/app/ui/ai/AIChatCoordinator.java | 90 +++++++++++ .../ui/ai/backend/AIBackendMockService.java | 40 +++++ 3 files changed, 185 insertions(+), 87 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java diff --git a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java index 7b68758fc3..425cdeec3c 100644 --- a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java @@ -20,18 +20,10 @@ /** * AI Assistant floating panel UI layer for IPED. *

- * This class implements the visual UI of the AI Assistant. It is a - * singleton and provides a floating panel containing: - *

    - *
  • Chat display and input areas
  • - *
  • AI context file management list
  • - *
  • Quick action buttons
  • - *
  • Status and progress indicators
  • - *
- *

- *

- * Currently, this is strictly a UI layer for testing and does not include - * logic or LLM integration. + * This class acts as the "View" in our architecture. It is a Singleton that + * provides a floating panel containing the chat display, file context management, + * and progress indicators. It is entirely decoupled from the extraction and + * network logic, communicating only through the AIChatCoordinator. *

*/ public class AIAssistantPanel { @@ -50,6 +42,9 @@ public class AIAssistantPanel { private JLabel statusLabel; private JProgressBar progressBar; + // Coordinator (The Controller that handles business logic and threading) + private iped.app.ui.ai.AIChatCoordinator coordinator; + // Context-related UI components private JPanel contextPanel; private JList contextList; @@ -58,14 +53,9 @@ public class AIAssistantPanel { private JButton clearContextButton; private TitledBorder contextBorder; - // Singleton instance + // Singleton instance ensures only one floating panel exists at a time private static AIAssistantPanel instance; - /** - * Returns the singleton instance of the AI Assistant Panel. - * - * @return singleton instance - */ public static AIAssistantPanel getInstance() { if (instance == null) { instance = new AIAssistantPanel(); @@ -74,12 +64,19 @@ public static AIAssistantPanel getInstance() { } /** - * Private constructor: initializes UI components and registers context change listener. + * Private constructor: This is the application's wiring hub. */ private AIAssistantPanel() { + // Initialize the Controller. + // We use Dependency Injection here to pass the Mock Service. + this.coordinator = new iped.app.ui.ai.AIChatCoordinator( + new iped.app.ui.ai.backend.AIBackendMockService() + ); + createUI(); - // Listen for AI Context Changes to refresh context list + // Subscribe to the State Manager. + // We use the Observer pattern to passively listen for context changes. AIContextManager.getInstance().addContextChangeListener(new ContextChangeListener() { @Override public void contextChanged(ContextChangeEvent event) { @@ -88,9 +85,6 @@ public void contextChanged(ContextChangeEvent event) { }); } - /** - * Creates the floating panel UI including chat, context, and quick tasks. - */ private void createUI() { String title = "AI Assistant"; try { title = Messages.getString("AIAssistant.Title"); } catch (Exception e) {} @@ -101,47 +95,36 @@ private void createUI() { JPanel mainPanel = new JPanel(new BorderLayout(5, 5)); mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); - // Add header (title + status) mainPanel.add(createHeaderPanel(), BorderLayout.NORTH); - // Center panel contains context list and chat JPanel centerPanel = new JPanel(new BorderLayout(5, 5)); centerPanel.add(createContextSection(), BorderLayout.NORTH); - // Chat display area + // Chat display area setup chatArea = new JTextArea(); chatArea.setEditable(false); chatArea.setLineWrap(true); chatArea.setWrapStyleWord(true); chatArea.setFont(new Font("SansSerif", Font.PLAIN, 12)); chatArea.setBackground(new Color(245, 245, 245)); + JScrollPane chatScroll = new JScrollPane(chatArea); chatScroll.setPreferredSize(new Dimension(PANEL_WIDTH, 400)); - centerPanel.add(chatScroll, BorderLayout.CENTER); - // Quick action buttons JPanel tasksPanel = createTasksPanel(); centerPanel.add(tasksPanel, BorderLayout.EAST); mainPanel.add(centerPanel, BorderLayout.CENTER); - - // Input area and send button at the bottom mainPanel.add(createBottomPanel(), BorderLayout.SOUTH); dialog.getContentPane().add(mainPanel); dialog.pack(); positionDialog(); - // Initial system message - addMessage("System", "UI Layer Active. Still missing LLM integration. Right click files to add them to Context."); + addMessage("System", "UI Layer Active. Integrated with Coordinator and Mock Backend. Right click files to add them to Context."); } - /** - * Creates the header panel with title and status label. - * - * @return header panel - */ private JPanel createHeaderPanel() { JPanel headerPanel = new JPanel(new BorderLayout()); @@ -150,8 +133,8 @@ private JPanel createHeaderPanel() { JLabel titleLabel = new JLabel(titleText); titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14)); - statusLabel = new JLabel("● UI only, for now"); - statusLabel.setForeground(new Color(100, 100, 100)); + statusLabel = new JLabel("● Local Mock Backend"); // Updated status + statusLabel.setForeground(new Color(0, 150, 0)); // Make it green to show it's active JPanel leftPanel = new JPanel(new BorderLayout()); leftPanel.add(titleLabel, BorderLayout.NORTH); @@ -163,11 +146,6 @@ private JPanel createHeaderPanel() { return headerPanel; } - /** - * Creates the AI context section showing added files and clear button. - * - * @return context panel - */ private JPanel createContextSection() { contextListModel = new DefaultListModel<>(); contextList = new JList<>(contextListModel); @@ -175,7 +153,7 @@ private JPanel createContextSection() { contextList.setVisibleRowCount(5); contextList.setBackground(new Color(255, 255, 240)); - // Custom cell renderer to show file label and tooltip + // Custom Cell Renderer mapping our ContextFileEntry ViewModel to the screen contextList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { JLabel label = (JLabel) new DefaultListCellRenderer() .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); @@ -199,7 +177,6 @@ private JPanel createContextSection() { listContainer.add(contextScroll, BorderLayout.CENTER); listContainer.add(contextEmptyLabel, BorderLayout.NORTH); - // Clear button to remove all context files clearContextButton = new JButton("Clear"); clearContextButton.setMargin(new Insets(0, 5, 0, 5)); clearContextButton.setEnabled(false); @@ -221,7 +198,7 @@ private JPanel createContextSection() { } /** - * Updates the context list UI based on current AI context files. + * Destructive refresh: Wipes the UI list and rebuilds it from the Source of Truth. */ private void refreshContextUI() { List files = AIContextManager.getInstance().getContextFiles(); @@ -244,11 +221,6 @@ private void refreshContextUI() { contextPanel.repaint(); } - /** - * Creates the panel containing quick action buttons. - * - * @return tasks panel - */ private JPanel createTasksPanel() { JPanel panel = new JPanel(); panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); @@ -259,18 +231,17 @@ private JPanel createTasksPanel() { JButton btn = new JButton(task); btn.setAlignmentX(Component.CENTER_ALIGNMENT); btn.setMaximumSize(new Dimension(200, 30)); - btn.addActionListener(e -> addMessage("Action", "Requested: " + task)); + // Firing a pre-written prompt directly into the input area logic + btn.addActionListener(e -> { + inputArea.setText(task + " the provided file."); + handleSendAction(); + }); panel.add(btn); panel.add(Box.createVerticalStrut(5)); } return panel; } - /** - * Creates the bottom panel containing input area, send button, and progress bar. - * - * @return bottom panel - */ private JPanel createBottomPanel() { JPanel bottomPanel = new JPanel(new BorderLayout(5, 5)); @@ -281,11 +252,13 @@ private JPanel createBottomPanel() { inputArea = new JTextArea(6, 20); inputArea.setLineWrap(true); inputArea.setBorder(BorderFactory.createLineBorder(Color.GRAY)); + + // Listen for "Enter" key to trigger send, allowing "Shift+Enter" for new lines inputArea.addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_ENTER && !e.isShiftDown()) { - e.consume(); + e.consume(); // Prevent adding a newline character handleSendAction(); } } @@ -304,9 +277,6 @@ public void keyPressed(KeyEvent e) { return bottomPanel; } - /** - * Positions the floating dialog on the screen with offsets. - */ private void positionDialog() { GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment() .getDefaultScreenDevice().getDefaultConfiguration(); @@ -326,23 +296,37 @@ private void positionDialog() { } /** - * Handles sending the user's input message. + * The main execution block linking user intent to the background Coordinator. */ private void handleSendAction() { String text = inputArea.getText().trim(); if (!text.isEmpty()) { + // Print user message immediately addMessage("User", text); inputArea.setText(""); - simulateProcessing(); + + // Lock the UI + setProcessing(true); + + // Call the coordinator + coordinator.askQuestion( + text, + // Callback 1: Stream chunks to the chat area safely on the UI thread + (token) -> javax.swing.SwingUtilities.invokeLater(() -> { + chatArea.append(token); + chatArea.setCaretPosition(chatArea.getDocument().getLength()); + }), + // Callback 2: Unlock the UI when done + () -> javax.swing.SwingUtilities.invokeLater(() -> setProcessing(false)), + // Callback 3: Handle Errors + (errorMessage) -> javax.swing.SwingUtilities.invokeLater(() -> { + addMessage("System Error", errorMessage); + setProcessing(false); + }) + ); } } - /** - * Appends a message to the chat area with timestamp. - * - * @param sender name of the sender (e.g., User, System) - * @param message message text - */ private void addMessage(String sender, String message) { String time = new SimpleDateFormat("HH:mm").format(new Date()); chatArea.append(String.format("[%s] %s: %s\n\n", time, sender, message)); @@ -350,17 +334,7 @@ private void addMessage(String sender, String message) { } /** - * Simulates AI processing by showing the progress bar briefly. - */ - private void simulateProcessing() { - setProcessing(true); - new javax.swing.Timer(1000, e -> setProcessing(false)).start(); - } - - /** - * Sets the UI into a processing state. - * - * @param processing true if processing, false otherwise + * Locks or unlocks the input fields and displays the loading bar. */ private void setProcessing(boolean processing) { progressBar.setVisible(processing); @@ -369,9 +343,6 @@ private void setProcessing(boolean processing) { dialog.setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) : Cursor.getDefaultCursor()); } - /** - * Toggles the visibility of the AI Assistant panel. - */ public void toggleVisibility() { if (dialog.isVisible()) { dialog.setVisible(false); @@ -381,9 +352,6 @@ public void toggleVisibility() { } } - /** - * Shows the panel if hidden, and requests input focus. - */ public void showPanel() { if (!dialog.isVisible()) { dialog.setVisible(true); diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java new file mode 100644 index 0000000000..183c7b7e40 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -0,0 +1,90 @@ +package iped.app.ui.ai; + +import iped.app.ui.ai.backend.AIBackendException; +import iped.app.ui.ai.backend.AIBackendService; +import iped.data.IItem; +import java.util.List; +import java.util.function.Consumer; + +/** + * The central orchestrator that coordinates the flow of data between the UI, + * the file extraction utilities, and the AI backend service. + *

+ * This class isolates the complexity of threading, session caching, and error handling + * from the presentation layer. It ensures that heavy operations (like extracting files + * and making network calls) are safely routed off the main UI thread. + *

+ */ +public class AIChatCoordinator { + + private final AIBackendService backendService; + private final AIWhatsappChatExtractor extractor; + + // Cache the hash so we don't re-upload the same chat every time + private String currentChatHash = null; + private IItem currentContextItem = null; + + /** + * Constructs a new coordinator. + * * @param backendService The backend client (can be a Mock or HTTP client) injected + * for handling actual AI communication. + */ + public AIChatCoordinator(AIBackendService backendService) { + this.backendService = backendService; + this.extractor = new AIWhatsappChatExtractor(); + } + + /** + * Executes the complete AI request pipeline: validates the selected file, + * uploads the context (if necessary), and streams the AI's response. + * @param question The text prompt from the user. + * @param uiCallback A callback used to push status updates and streamed text back to the UI. + * @param onComplete A callback triggered when the entire process finishes (success or fail), + * typically used to re-enable UI buttons. + * @param onError A callback used to push error messages back to the UI. + */ + public void askQuestion(String question, Consumer uiCallback, Runnable onComplete, Consumer onError) { + // Validate Context + List contextFiles = AIContextManager.getInstance().getContextFiles(); + + if (contextFiles.isEmpty()) { + onError.accept("Please, add a file to context before asking."); + return; + } + if (contextFiles.size() > 1) { + onError.accept("Only one file is currently supported, remove the extra ones."); + return; + } + + IItem item = contextFiles.get(0); + + // Offload heavy lifting to a background thread + new Thread(() -> { + try { + // Step A: Extract and Initialize Chat + // If the user uploads a new chat to context, item will refer to + // the new chat and currentContextItem will refer to the last chat + // then, the condition is fulfilled and a new chat is initialized + if (currentChatHash == null || !item.equals(currentContextItem)) { + uiCallback.accept("[System]: Extracting and sending file...\n\n"); + String html = extractor.extractHtml(item); + currentChatHash = backendService.initChat(html); + currentContextItem = item; // Update cache + } + + // Step B: Stream the response + uiCallback.accept("[Assistant]: "); + backendService.streamChatResponse(currentChatHash, question, uiCallback); + uiCallback.accept("\n\n"); + + } catch (Exception e) { + onError.accept("backend error: " + e.getMessage()); + // Invalidate the cache on error so the next attempt tries a fresh upload + currentChatHash = null; + } finally { + // Step C: unlock the UI + onComplete.run(); + } + }).start(); + } +} \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java new file mode 100644 index 0000000000..99c8940666 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java @@ -0,0 +1,40 @@ +package iped.app.ui.ai.backend; + +import java.util.function.Consumer; + +/** + * A mock implementation of the {@link AIBackendService} used for UI testing and local development. + *

+ * This class simulates the latency and token-by-token streaming behavior of a real + * Large Language Model (LLM) over a network. It allows the frontend application to be + * built, tested, and debugged without requiring a live connection to the backend. + *

+ */ +public class AIBackendMockService implements AIBackendService{ + + @Override + public String initChat(String chatHtml) throws AIBackendException{ + // Simulate server processing delay + try {Thread.sleep(1000); } catch (InterruptedException ignored) {} + + // Return fake hash that represents session ID + return "mock_hash_12345abcde"; + } + + @Override + public void streamChatResponse(String chatHash, String question, Consumer eventHandler) throws AIBackendException { + // Simulate the chunked responses (Server-Sent Events) + String[] mockTokens = { + "Based ", "on ", "the ", "provided ", "file, ", "this ", "is ", "a ", "simulated ", "response." + }; + + for (String token : mockTokens) { + try { + Thread.sleep(150); // Delay between tokens to simulate typing/streaming + } catch (InterruptedException ignored) {} + + // Push the generated token back to the UI + eventHandler.accept(token); + } + } +} From 2ff24148e4c08695706a3ba6c585386a7d3708b3 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 14 Apr 2026 15:30:38 -0300 Subject: [PATCH 038/143] feat(llm integration): add backend http client and runtime config --- .../app/ui/ai/backend/AIBackendClient.java | 183 ++++++++++++++++-- .../app/ui/ai/backend/AIBackendConfig.java | 24 ++- 2 files changed, 191 insertions(+), 16 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index 1bb0695499..7619401419 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -1,37 +1,194 @@ package iped.app.ui.ai.backend; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.function.Consumer; /** - * The concrete implementation of the {@link AIBackendService} that handles HTTP - * communication with the AI backend. - *

- * This class is responsible for taking the application's requests, formatting them - * into the appropriate network payloads, executing the HTTP calls using - * the provided {@link AIBackendConfig}, and parsing the responses back to the application. - *

+ * Concrete implementation of {@link AIBackendService} responsible for handling + * communication with the AI backend over HTTP. + * + *

This client supports two primary operations:

+ *
    + *
  • Chat initialization via a synchronous HTTP POST request
  • + *
  • Streaming responses via Server-Sent Events (SSE)
  • + *
+ * + *

It uses Java's {@link HttpClient} for network communication and {@link Gson} + * for JSON serialization/deserialization.

+ * + *

Thread safety: This class is thread-safe provided that the supplied + * {@link AIBackendConfig} is immutable.

*/ public class AIBackendClient implements AIBackendService { private final AIBackendConfig config; + private final HttpClient httpClient; + private final Gson gson; /** - * Constructs a new AIBackendClient with the specified configuration. - * @param config The AI backend configuration (must not be null). + * Constructs a new {@code AIBackendClient}. + * + * @param config Backend configuration (must not be null). + * @throws IllegalArgumentException if config is null */ public AIBackendClient(AIBackendConfig config) { this.config = config; + this.gson = new Gson(); + // Create an HTTP client with a reasonable timeout + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); } + /** + * Initializes a new chat session with the backend. + * + *

This method sends the initial chat content (HTML formatted) to the backend, + * which responds with a chat identifier

+ * + * @param chatHtml Initial chat content in HTML format + * @return The MD5 chat_hash returned by the server. + * @throws AIBackendException if: + *
    + *
  • The HTTP request fails
  • + *
  • The backend returns a non-200 status
  • + *
  • The backend returns an application-level error
  • + *
  • The response cannot be parsed
  • + *
+ */ @Override public String initChat(String chatHtml) throws AIBackendException { - // TODO: Implement later (step 4) - throw new UnsupportedOperationException("Backend HTTP calls are not yet implemented."); + try { + // Construct the JSON payload {"chat_content": "..."} + JsonObject payload = new JsonObject(); + payload.addProperty("chat_content", chatHtml); + String jsonBody = gson.toJson(payload); + + // Build the POST request targeting the initialization endpoint + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.getBaseUrl() + "/api/init_chat")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + config.getApiKey()) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) + .build(); + + // Send synchronously + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + // Validate HTTP response + if (response.statusCode() != 200) { + throw new AIBackendException("Backend returned HTTP " + response.statusCode() + ": " + response.body()); + } + + // Parse the JSON response + JsonObject responseJson = JsonParser.parseString(response.body()).getAsJsonObject(); + + // Check backend level error + if (responseJson.has("error")) { + throw new AIBackendException("Backend Error: " + responseJson.get("error").getAsString()); + } + + return responseJson.get("response").getAsString(); + + } catch (Exception e) { + throw new AIBackendException("Failed to initialize chat: " + e.getMessage(), e); + } } + /** + * Streams a chat response from the backend using Server-Sent Events (SSE). + * + *

This method sends a user question and listens for incremental responses + * from the backend. Each chunk of data is processed and forwarded to the UI + * via the provided {@link Consumer}.

+ * + * @param chatHash Unique identifier of the chat session + * @param question User's input question + * @param eventHandler Callback invoked for each streamed content chunk + * @throws AIBackendException if: + *
    + *
  • The HTTP request fails
  • + *
  • The backend returns a non-200 status
  • + *
  • A streaming error occurs
  • + *
  • The stream cannot be read or parsed
  • + *
+ */ @Override public void streamChatResponse(String chatHash, String question, Consumer eventHandler) throws AIBackendException { - // TODO: Implement later (step 4) - throw new UnsupportedOperationException("Backend HTTP calls are not yet implemented."); + try { + // Construct the JSON payload for the query + JsonObject payload = new JsonObject(); + payload.addProperty("chat_hash", chatHash); + payload.addProperty("user_question", question); + payload.add("previousmessages", new JsonArray()); // Future multi-turn support + + String jsonBody = gson.toJson(payload); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.getBaseUrl() + "/api/chat/stream")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + config.getApiKey()) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) + .build(); + + // response as an InputStream so we can process data as it arrives. + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + throw new AIBackendException("Backend returned HTTP " + response.statusCode()); + } + + // Process the Server-Sent Events (SSE) stream + try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { + String line; + + // Blocks here until a new line arrives over the network, then processes it + while ((line = reader.readLine()) != null) { + + // SSE protocol dictates data lines begin with "data: " + if (line.startsWith("data: ")) { + String jsonData = line.substring(6).trim(); // Strips the "data: " + + // Ignore keep-alive pings or the final closure signal + if (jsonData.isEmpty() || jsonData.equals("[DONE]")) continue; + + // Parse the JSON, figure what type of message it is + JsonObject eventObj = JsonParser.parseString(jsonData).getAsJsonObject(); + String type = eventObj.has("type") ? eventObj.get("type").getAsString() : ""; + + // Isolate the actual content + if (eventObj.has("content")) { + String content = eventObj.get("content").getAsString(); + + // Route the token to the UI using the Consumer callback + if (type.equals("status") || type.equals("thinking")) { + // Format metadata in italics for the UI + eventHandler.accept("\n_" + content + "_\n"); + } else if (type.equals("final")) { + // Push the raw LLM token directly to the screen + eventHandler.accept(content); + } else if (type.equals("error")) { + throw new AIBackendException("Backend Streaming Error: " + content); + } + } + } + } + } + + } catch (Exception e) { + throw new AIBackendException("Failed to stream chat response: " + e.getMessage(), e); + } } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java index 1fe29d4167..1aefec4297 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java @@ -14,7 +14,7 @@ public class AIBackendConfig { // The root URL of the AI API endpoint private final String baseUrl; - /** * The authentication key required to access the AI service. + /** The authentication key required to access the AI service. * This may be an empty string or null if connecting to an unsecured local model. */ private final String apiKey; @@ -32,6 +32,24 @@ public String getApiKey() { return apiKey; } - // Note: Future integration point for loading these values directly from - // IPED's configuration property files (e.g., using iped.engine.config.ConfigurationManager) + /** + * Command-Line Configuration (Current) + *

+ * Currently, this loads the configuration from Java System Properties + * (looks for terminal flags passed when IPED starts up) + *

+ *

+ * If no flags are provided, it safely defaults to a local developer setup. + *

+ * TODO: IPED Global Configuration Integration + *

This method should eventually be replaced or updated to read directly + * from IPED's configuration files

+ * @return A fully initialized AIBackendConfig. + */ + public static AIBackendConfig loadFromSystemProperties() { + // Default to local SARD backend port 8000 + String url = System.getProperty("iped.ai.url", "http://10.61.86.244:32058"); + String key = System.getProperty("iped.ai.key", "change-me"); + return new AIBackendConfig(url, key); + } } \ No newline at end of file From aa2f15e44a98547f7ac9c5f718ab43828d879806 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Wed, 15 Apr 2026 14:29:05 -0300 Subject: [PATCH 039/143] fix(ai-backend): align HTTP client with FastAPI and add local integration test by running the backend server locally through docker --- .../app/ui/ai/backend/AIBackendClient.java | 13 ++-- .../ui/ai/backend/AIBackendClientTest.java | 62 +++++++++++++++++++ .../app/ui/ai/backend/AIBackendConfig.java | 12 ++-- 3 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClientTest.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index 7619401419..bcfefd2625 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -46,8 +46,9 @@ public class AIBackendClient implements AIBackendService { public AIBackendClient(AIBackendConfig config) { this.config = config; this.gson = new Gson(); - // Create an HTTP client with a reasonable timeout + // Create an HTTP client explicitly locked to HTTP/1.1 (required for the backend) this.httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(10)) .build(); } @@ -79,8 +80,9 @@ public String initChat(String chatHtml) throws AIBackendException { // Build the POST request targeting the initialization endpoint HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(config.getBaseUrl() + "/api/init_chat")) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + config.getApiKey()) + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .header("Authorization", "Bearer " + config.getapiKey()) .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) .build(); @@ -138,8 +140,9 @@ public void streamChatResponse(String chatHash, String question, Consumer" + + "
" + + "
" + + " 21/03/2025 14:30:00" + + " João Silva" + + " Bom dia, qual o preço do pão hoje?" + + "
" + + "
" + + "
" + + "
" + + " 21/03/2025 14:32:00" + + " Carlos" + + " Está caro..." + + "
" + + "
" + + ""; + + try { + // Test init Chat + System.out.println("Testing /api/init_chat..."); + String chatHash = client.initChat(mockHtml); + System.out.println("Server returned Chat Hash: " + chatHash + "\n"); + + // test stream chat + System.out.println("Phase 2: Testing /api/chat/stream..."); + String question = "Qual o nome de quem respondeu a pergunta de João? Quantos anos ele tem?"; + System.out.println("Question: " + question); + System.out.print("Assistant Response: "); + + // Call the stream method. The lambda prints tokens exactly as they arrive. + client.streamChatResponse(chatHash, question, token -> { + System.out.print(token); + System.out.flush(); // Force print to console immediately + }); + + System.out.println("\n\nStream completed successfully"); + + } catch (AIBackendException e) { + System.err.println("\nTest Failed: AI Backend Error"); + e.printStackTrace(); + } catch (Exception e) { + System.err.println("\nTest Failed: Unexpected Java Error"); + e.printStackTrace(); + } + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java index 1aefec4297..e289b2bc72 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java @@ -14,9 +14,7 @@ public class AIBackendConfig { // The root URL of the AI API endpoint private final String baseUrl; - /** The authentication key required to access the AI service. - * This may be an empty string or null if connecting to an unsecured local model. - */ + // The authentication key required to access the AI service. private final String apiKey; public AIBackendConfig(String baseUrl, String apiKey) { @@ -28,7 +26,7 @@ public String getBaseUrl() { return baseUrl; } - public String getApiKey() { + public String getapiKey() { return apiKey; } @@ -48,8 +46,8 @@ public String getApiKey() { */ public static AIBackendConfig loadFromSystemProperties() { // Default to local SARD backend port 8000 - String url = System.getProperty("iped.ai.url", "http://10.61.86.244:32058"); - String key = System.getProperty("iped.ai.key", "change-me"); - return new AIBackendConfig(url, key); + String url = System.getProperty("iped.ai.url", "http://localhost:8000"); + String apiKey = System.getProperty("iped.ai.key", "change-me"); + return new AIBackendConfig(url, apiKey); } } \ No newline at end of file From ce12b6cd0b88c48b82971e7650886e26f184f423 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Wed, 15 Apr 2026 14:52:16 -0300 Subject: [PATCH 040/143] feat(llm integration): fully integrate IPED's UI with the local backend server --- .../src/main/java/iped/app/ui/AIAssistantPanel.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java index 425cdeec3c..4ceeb817ad 100644 --- a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java @@ -15,6 +15,8 @@ import iped.app.ui.ai.ContextChangeEvent; import iped.app.ui.ai.ContextChangeListener; import iped.app.ui.ai.ContextFileEntry; +import iped.app.ui.ai.backend.AIBackendClient; +import iped.app.ui.ai.backend.AIBackendConfig; import iped.data.IItem; /** @@ -68,9 +70,8 @@ public static AIAssistantPanel getInstance() { */ private AIAssistantPanel() { // Initialize the Controller. - // We use Dependency Injection here to pass the Mock Service. this.coordinator = new iped.app.ui.ai.AIChatCoordinator( - new iped.app.ui.ai.backend.AIBackendMockService() + new AIBackendClient(AIBackendConfig.loadFromSystemProperties()) ); createUI(); @@ -122,7 +123,7 @@ private void createUI() { dialog.pack(); positionDialog(); - addMessage("System", "UI Layer Active. Integrated with Coordinator and Mock Backend. Right click files to add them to Context."); + addMessage("System", "AI Assistant ready. Connected to local Backend server.\nRight-click an HTML WhatsApp chat export to add it to the context, then type your question."); } private JPanel createHeaderPanel() { @@ -133,8 +134,9 @@ private JPanel createHeaderPanel() { JLabel titleLabel = new JLabel(titleText); titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14)); - statusLabel = new JLabel("● Local Mock Backend"); // Updated status - statusLabel.setForeground(new Color(0, 150, 0)); // Make it green to show it's active + // Update status label to indicate live connection + statusLabel = new JLabel("● Connected to local backend server"); + statusLabel.setForeground(new Color(0, 150, 0)); // Green for active JPanel leftPanel = new JPanel(new BorderLayout()); leftPanel.add(titleLabel, BorderLayout.NORTH); From a82cce7d00e4d309190534cf5694229a341ce3c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Wed, 15 Apr 2026 15:58:10 -0300 Subject: [PATCH 041/143] feat(ai): enhance AI context management with new menu options and payload builder --- .../iped-desktop-messages.properties | 2 + .../iped-desktop-messages_de_DE.properties | 2 + .../iped-desktop-messages_es_AR.properties | 2 + .../iped-desktop-messages_fr_FR.properties | 2 + .../iped-desktop-messages_it_IT.properties | 2 + .../iped-desktop-messages_pt_BR.properties | 2 + .../java/iped/app/ui/AIAssistantPanel.java | 27 +- .../src/main/java/iped/app/ui/MenuClass.java | 73 +++-- .../java/iped/app/ui/ai/AIContextManager.java | 274 ++++++++++++++++-- .../ui/ai/AIConversationPayloadBuilder.java | 152 ++++++++++ .../app/ui/ai/AIWhatsappChatExtractor.java | 21 +- .../java/iped/app/ui/ai/ContextFileEntry.java | 153 ++++++++++ 12 files changed, 632 insertions(+), 80 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/AIConversationPayloadBuilder.java diff --git a/iped-app/resources/localization/iped-desktop-messages.properties b/iped-app/resources/localization/iped-desktop-messages.properties index 45e998b73d..84c4a53357 100644 --- a/iped-app/resources/localization/iped-desktop-messages.properties +++ b/iped-app/resources/localization/iped-desktop-messages.properties @@ -221,6 +221,8 @@ FaceSimilarity.MinScore=Minimum similarity (1-100) FaceSimilarity.LoadingFace=Loading Face... FaceSimilarity.ExternalFaceNotFound=Face not found in external file! MenuClass.AddToGraph=Add to link analysis +MenuClass.AddToAIContext=Add to AI context +MenuClass.AddAllMarkedToAIContext=Add all marked to AI context MenuClass.ChangeGalleryColCount=Change Gallery Column Count MenuClass.ChangeLayout=Change Vertical/Horizontal Layout MenuClass.CheckHighlighted=Check Highlighted items diff --git a/iped-app/resources/localization/iped-desktop-messages_de_DE.properties b/iped-app/resources/localization/iped-desktop-messages_de_DE.properties index 7edec9279d..249e007b3a 100644 --- a/iped-app/resources/localization/iped-desktop-messages_de_DE.properties +++ b/iped-app/resources/localization/iped-desktop-messages_de_DE.properties @@ -218,6 +218,8 @@ FaceSimilarity.MinScore=Minimum an ähnlichkeit (1-100) FaceSimilarity.LoadingFace=Lade Gesicht... FaceSimilarity.ExternalFaceNotFound=Gesicht in externer Datei nicht gefunden! MenuClass.AddToGraph=Zur Link-Analyse hinzufügen +MenuClass.AddToAIContext=Zum KI-Kontext hinzufügen +MenuClass.AddAllMarkedToAIContext=Alle markierten zum KI-Kontext hinzufügen MenuClass.ChangeGalleryColCount=Ändere Spaltenanzahl in der Galerie MenuClass.ChangeLayout=Ändere vertikales/horizontales Layout MenuClass.CheckHighlighted=Setze Haken bei markierten Elementen diff --git a/iped-app/resources/localization/iped-desktop-messages_es_AR.properties b/iped-app/resources/localization/iped-desktop-messages_es_AR.properties index 49cdd687a0..ce81e2b2b7 100644 --- a/iped-app/resources/localization/iped-desktop-messages_es_AR.properties +++ b/iped-app/resources/localization/iped-desktop-messages_es_AR.properties @@ -218,6 +218,8 @@ FaceSimilarity.MinScore=Similitud mínima (1-100) FaceSimilarity.LoadingFace=Cargando rostro... FaceSimilarity.ExternalFaceNotFound=¡Cara no encontrada en el archivo externo! MenuClass.AddToGraph=Añadir al análisis de enlaces +MenuClass.AddToAIContext=Añadir al contexto de IA +MenuClass.AddAllMarkedToAIContext=Añadir todos los marcados al contexto de IA MenuClass.ChangeGalleryColCount=Cambiar número de columnas de la galería MenuClass.ChangeLayout=Cambiar diseño vertical/horizontal MenuClass.CheckHighlighted=Comprobar elementos resaltados diff --git a/iped-app/resources/localization/iped-desktop-messages_fr_FR.properties b/iped-app/resources/localization/iped-desktop-messages_fr_FR.properties index b6a6e6d11d..547e146f08 100644 --- a/iped-app/resources/localization/iped-desktop-messages_fr_FR.properties +++ b/iped-app/resources/localization/iped-desktop-messages_fr_FR.properties @@ -218,6 +218,8 @@ FaceSimilarity.MinScore=Précision de ressemblance (1-100) FaceSimilarity.LoadingFace=Chargement du visage... FaceSimilarity.ExternalFaceNotFound=Pas de visage trouvé dans le fichier externe ! MenuClass.AddToGraph=Ajouter à l''analyse de liens +MenuClass.AddToAIContext=Ajouter au contexte IA +MenuClass.AddAllMarkedToAIContext=Ajouter tous les éléments marqués au contexte IA MenuClass.ChangeGalleryColCount=Modifier le nombre de colonnes de la galerie MenuClass.ChangeLayout=Changer la disposition Vertical/Horizontal MenuClass.CheckHighlighted=Cocher les éléments en surbrillance diff --git a/iped-app/resources/localization/iped-desktop-messages_it_IT.properties b/iped-app/resources/localization/iped-desktop-messages_it_IT.properties index 03b2c34311..088f033a81 100644 --- a/iped-app/resources/localization/iped-desktop-messages_it_IT.properties +++ b/iped-app/resources/localization/iped-desktop-messages_it_IT.properties @@ -218,6 +218,8 @@ FaceSimilarity.MinScore=Percentuale minima di somiglianza (1-100) FaceSimilarity.LoadingFace=Caricamento volto... FaceSimilarity.ExternalFaceNotFound=Volto non trovato nel file esterno! MenuClass.AddToGraph=Aggiungi all''analisi dei collegamenti +MenuClass.AddToAIContext=Aggiungi al contesto IA +MenuClass.AddAllMarkedToAIContext=Aggiungi tutti gli elementi contrassegnati al contesto IA MenuClass.ChangeGalleryColCount=Cambia il numero di colonne della Galleria MenuClass.ChangeLayout=Cambia layout verticale/orizzontale MenuClass.CheckHighlighted=Seleziona gli elementi evidenziati diff --git a/iped-app/resources/localization/iped-desktop-messages_pt_BR.properties b/iped-app/resources/localization/iped-desktop-messages_pt_BR.properties index 6eb2c25725..1aa5194f26 100644 --- a/iped-app/resources/localization/iped-desktop-messages_pt_BR.properties +++ b/iped-app/resources/localization/iped-desktop-messages_pt_BR.properties @@ -218,7 +218,9 @@ FaceSimilarity.MinScore=Similaridade mínima (1-100) FaceSimilarity.LoadingFace=Carregando Face... FaceSimilarity.ExternalFaceNotFound=Nenhuma face encontrada na imagem externa! MenuClass.AddToGraph=Adicionar à análise de vínculos +MenuClass.AddToAIContext=Adicionar ao contexto de IA MenuClass.ChangeGalleryColCount=Alterar Nº Colunas da Galeria +MenuClass.AddAllMarkedToAIContext=Adicionar todos os marcados ao contexto de IA MenuClass.ChangeLayout=Alterar Disposição Vertical/Horizontal MenuClass.CheckHighlighted=Marcar itens destacados MenuClass.CheckAdvanced=Marcação avançada... diff --git a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java index 7b68758fc3..36774417c8 100644 --- a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java @@ -181,8 +181,15 @@ private JPanel createContextSection() { .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); if (value instanceof ContextFileEntry) { ContextFileEntry entry = (ContextFileEntry) value; - label.setText(entry.getDisplayLabel()); - label.setToolTipText(entry.getFullPath()); + if (entry.isValidForContext()) { + label.setText(entry.getDisplayLabel()); + label.setToolTipText(entry.getFullPath()); + } else { + String reason = entry.getValidationReason() != null ? entry.getValidationReason() : "Rejected item."; + label.setText(entry.getFileName() + " - " + reason); + label.setToolTipText(reason + " Path: " + entry.getFullPath()); + label.setForeground(new Color(180, 0, 0)); + } } return label; }); @@ -224,10 +231,12 @@ private JPanel createContextSection() { * Updates the context list UI based on current AI context files. */ private void refreshContextUI() { - List files = AIContextManager.getInstance().getContextFiles(); + List entries = AIContextManager.getInstance().getContextEntriesForUI(); + List validFiles = AIContextManager.getInstance().getContextFiles(); + int invalidCount = entries.size() - validFiles.size(); contextListModel.clear(); - if (files.isEmpty()) { + if (entries.isEmpty()) { contextEmptyLabel.setVisible(true); contextList.setVisible(false); clearContextButton.setEnabled(false); @@ -235,12 +244,16 @@ private void refreshContextUI() { contextEmptyLabel.setVisible(false); contextList.setVisible(true); clearContextButton.setEnabled(true); - for (IItem file : files) { - contextListModel.addElement(new ContextFileEntry(file)); + for (ContextFileEntry entry : entries) { + contextListModel.addElement(entry); } } - contextBorder.setTitle("Added Context (" + files.size() + " files)"); + if (invalidCount > 0) { + contextBorder.setTitle("Added Context (" + validFiles.size() + " valid, " + invalidCount + " rejected)"); + } else { + contextBorder.setTitle("Added Context (" + validFiles.size() + " valid)"); + } contextPanel.repaint(); } diff --git a/iped-app/src/main/java/iped/app/ui/MenuClass.java b/iped-app/src/main/java/iped/app/ui/MenuClass.java index 2c6e6669c5..cc067259ef 100644 --- a/iped-app/src/main/java/iped/app/ui/MenuClass.java +++ b/iped-app/src/main/java/iped/app/ui/MenuClass.java @@ -21,6 +21,7 @@ import java.awt.event.ActionListener; import java.awt.event.KeyEvent; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import javax.swing.JComponent; @@ -51,7 +52,7 @@ public class MenuClass extends JPopupMenu { JMenuItem exportHighlighted, copyHighlighted, checkHighlighted, uncheckHighlighted, readHighlighted, unreadHighlighted, exportChecked, copyChecked, saveBookmarks, loadBookmarks, checkHighlightedAndSubItems, uncheckHighlightedAndSubItems, checkHighlightedAndParent, uncheckHighlightedAndParent, checkHighlightedAndReferences, uncheckHighlightedAndReferences, checkHighlightedAndReferencedBy, uncheckHighlightedAndReferencedBy, changeGalleryColCount, defaultLayout, changeLayout, previewScreenshot, manageBookmarks, clearSearchHistory, importKeywords, navigateToParent, exportTerms, manageFilters, manageColumns, exportCheckedToZip, exportCheckedTreeToZip, - exportTree, exportTreeChecked, similarDocs, openViewfile, createReport, resetColLayout, lastColLayout, saveColLayout, addToGraph, addToAIContext, navigateToParentChat, pinFirstColumns, similarImagesCurrent, similarImagesExternal, + exportTree, exportTreeChecked, similarDocs, openViewfile, createReport, resetColLayout, lastColLayout, saveColLayout, addToGraph, addToAIContext, addAllMarkedToAIContext, navigateToParentChat, pinFirstColumns, similarImagesCurrent, similarImagesExternal, similarFacesCurrent, similarFacesExternal, toggleTimelineView, uiZoom, catIconSize, savePanelsLayout, loadPanelsLayout; MenuListener menuListener = new MenuListener(this); @@ -335,32 +336,35 @@ public void actionPerformed(ActionEvent e) { this.add(addToGraph); // AI Context - String aiContextText = "Add to AI Context"; - try { aiContextText = Messages.getString("MenuClass.AddToAIContext"); } catch(Exception ignored) {} - - addToAIContext = new JMenuItem(aiContextText); + addToAIContext = new JMenuItem(Messages.getString("MenuClass.AddToAIContext")); //$NON-NLS-1$ try { addToAIContext.setIcon(iped.utils.IconUtil.getToolbarIcon("ai-assistant", App.resPath)); } catch (Exception ignored) {} addToAIContext.setEnabled(item != null); addToAIContext.addActionListener(e -> { - // Run on background thread to prevent UI freezing if many files are selected new Thread(() -> { - List itemsToAdd = getSelectedOrCheckedItems(item); - + AIContextManager.getInstance().addContextFile(item); + SwingUtilities.invokeLater(() -> AIAssistantPanel.getInstance().showPanel()); + }).start(); + }); + this.add(addToAIContext); + + addAllMarkedToAIContext = new JMenuItem(Messages.getString("MenuClass.AddAllMarkedToAIContext")); //$NON-NLS-1$ + try { + addAllMarkedToAIContext.setIcon(iped.utils.IconUtil.getToolbarIcon("ai-assistant", App.resPath)); + } catch (Exception ignored) {} + + addAllMarkedToAIContext.addActionListener(e -> { + new Thread(() -> { + List itemsToAdd = getMarkedItems(); if (!itemsToAdd.isEmpty()) { - // Add all items in a single batch operation AIContextManager.getInstance().addContextFiles(itemsToAdd); - - // UI updates must happen on the Event Dispatch Thread - SwingUtilities.invokeLater(() -> { - AIAssistantPanel.getInstance().showPanel(); - }); + SwingUtilities.invokeLater(() -> AIAssistantPanel.getInstance().showPanel()); } }).start(); }); - this.add(addToAIContext); + this.add(addAllMarkedToAIContext); this.addSeparator(); @@ -371,37 +375,26 @@ public void actionPerformed(ActionEvent e) { } // Helper Methods moved outside the constructor - private List getSelectedOrCheckedItems(IItem clickedItem) { - List selectedItems = new ArrayList<>(); + private List getMarkedItems() { + LinkedHashSet selectedItems = new LinkedHashSet<>(); JTable table = App.get().getResultsTable(); - + if (table != null && !isTreeMenu) { - // Check for "Checked" items - int checkCol = table.convertColumnIndexToView(1); - for (int i = 0; i < table.getRowCount(); i++) { - Boolean isChecked = (Boolean) table.getValueAt(i, checkCol); - if (Boolean.TRUE.equals(isChecked)) { - selectedItems.add(resolveItemFromRow(table, i)); - } - } - - // Fallback: Check for standard highlighted rows if nothing is checked - if (selectedItems.isEmpty()) { - int[] selectedRows = table.getSelectedRows(); - if (selectedRows.length > 1) { // More than just the clicked row - for (int row : selectedRows) { - selectedItems.add(resolveItemFromRow(table, row)); + // Checked state is stored in model column 1 (BaseTableModel), regardless of visible column order. + int checkedModelCol = 1; + Class colClass = table.getModel().getColumnClass(checkedModelCol); + if (colClass == Boolean.class || colClass == boolean.class) { + for (int viewRow = 0; viewRow < table.getRowCount(); viewRow++) { + int modelRow = table.convertRowIndexToModel(viewRow); + Object cellValue = table.getModel().getValueAt(modelRow, checkedModelCol); + if (Boolean.TRUE.equals(cellValue)) { + selectedItems.add(resolveItemFromRow(table, viewRow)); } } } } - - // Fallback: Just use the single right-clicked item - if (selectedItems.isEmpty() && clickedItem != null) { - selectedItems.add(clickedItem); - } - - return selectedItems; + + return new ArrayList<>(selectedItems); } private IItem resolveItemFromRow(JTable table, int viewRow) { diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIContextManager.java b/iped-app/src/main/java/iped/app/ui/ai/AIContextManager.java index 2e9e0ae5e9..3094a3c3c1 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIContextManager.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIContextManager.java @@ -7,6 +7,10 @@ import javax.swing.event.EventListenerList; import iped.data.IItem; +import iped.engine.lucene.analysis.CategoryTokenizer; +import iped.parsers.standard.StandardParser; +import iped.parsers.whatsapp.WhatsAppParser; +import iped.properties.ExtraProperties; /** * Singleton manager responsible for maintaining the AI context file list. @@ -32,6 +36,9 @@ public class AIContextManager { /** Thread-safe list holding context files */ private final List contextFiles; + + /** Invalid entries shown only in UI as feedback */ + private final List invalidEntries; /** Listener list for context change events */ private final EventListenerList listeners; @@ -41,6 +48,7 @@ public class AIContextManager { */ private AIContextManager() { this.contextFiles = new CopyOnWriteArrayList<>(); + this.invalidEntries = new CopyOnWriteArrayList<>(); this.listeners = new EventListenerList(); } @@ -62,15 +70,12 @@ public static synchronized AIContextManager getInstance() { * @param item the item to add; ignored if {@code null} */ public void addContextFile(IItem item) { - if (item == null) return; - - boolean alreadyExists = contextFiles.stream() - .anyMatch(existing -> existing.getId() == item.getId()); - - if (!alreadyExists) { - contextFiles.add(item); - fireContextChanged(ContextChangeEvent.FILE_ADDED); + if (item == null) { + return; } + List single = new ArrayList<>(1); + single.add(item); + addContextFiles(single); } /** @@ -79,7 +84,14 @@ public void addContextFile(IItem item) { * @param item the item to remove */ public void removeContextFile(IItem item) { - if (contextFiles.removeIf(existing -> existing.getId() == item.getId())) { + if (item == null) { + return; + } + + boolean removedValid = contextFiles.removeIf(existing -> existing.getId() == item.getId()); + boolean removedInvalid = invalidEntries.removeIf(entry -> entry.getItem().getId() == item.getId()); + + if (removedValid || removedInvalid) { fireContextChanged(ContextChangeEvent.FILE_REMOVED); } } @@ -88,8 +100,9 @@ public void removeContextFile(IItem item) { * Clears all context files. */ public void clearContext() { - if (!contextFiles.isEmpty()) { + if (!contextFiles.isEmpty() || !invalidEntries.isEmpty()) { contextFiles.clear(); + invalidEntries.clear(); fireContextChanged(ContextChangeEvent.CLEARED); } } @@ -102,6 +115,18 @@ public void clearContext() { public List getContextFiles() { return new ArrayList<>(contextFiles); } + + /** + * Returns entries to display in the UI: valid context files plus invalid items with reasons. + */ + public List getContextEntriesForUI() { + List result = new ArrayList<>(); + for (IItem file : contextFiles) { + result.add(new ContextFileEntry(file)); + } + result.addAll(invalidEntries); + return result; + } /** * Registers a listener to receive context change events. @@ -154,26 +179,229 @@ private void fireContextChanged(String changeType) { * @param items list of items to add; ignored if {@code null} or empty */ public void addContextFiles(List items) { - if (items == null || items.isEmpty()) return; - - boolean addedAny = false; - + if (items == null || items.isEmpty()) { + return; + } + + boolean changedAny = false; + for (IItem item : items) { - if (item == null) continue; - - // Deduplication check + if (item == null) { + continue; + } + + String rejectionReason = getRejectionReason(item); + if (rejectionReason != null) { + if (invalidEntries.removeIf(entry -> entry.getItem().getId() == item.getId())) { + changedAny = true; + } + invalidEntries.add(ContextFileEntry.invalid(item, rejectionReason)); + changedAny = true; + continue; + } + + // Item became valid, ensure it is no longer flagged as invalid + if (invalidEntries.removeIf(entry -> entry.getItem().getId() == item.getId())) { + changedAny = true; + } + boolean alreadyExists = contextFiles.stream() - .anyMatch(existing -> existing.getId() == item.getId()); - + .anyMatch(existing -> existing.getId() == item.getId()); + if (!alreadyExists) { contextFiles.add(item); - addedAny = true; + changedAny = true; } } - - // Fire a single event after the entire batch is processed - if (addedAny) { + + if (changedAny) { fireContextChanged(ContextChangeEvent.FILE_ADDED); } } + + private String getRejectionReason(IItem item) { + if (!isWhatsAppChatItem(item)) { + return "Rejected: Not a WhatsApp chat item."; + } + + if (hasEmptyFilesCategory(item)) { + return "Rejected: Category is Empty Files."; + } + + if (hasSummary(item)) { + return null; + } + + Boolean isEmpty = readCommunicationIsEmpty(item); + if (Boolean.TRUE.equals(isEmpty)) { + return "Rejected: Communication is empty."; + } + return null; + } + + private boolean isWhatsAppChatItem(IItem item) { + if (item == null) { + return false; + } + + String chatContentType = WhatsAppParser.WHATSAPP_CHAT.toString(); + if (item.getMediaType() != null && chatContentType.equals(item.getMediaType().toString())) { + return true; + } + + if (item.getMetadata() != null) { + String indexedContentType = item.getMetadata().get(StandardParser.INDEXER_CONTENT_TYPE); + if (chatContentType.equals(indexedContentType)) { + return true; + } + } + + return false; + } + + private boolean hasEmptyFilesCategory(IItem item) { + if (item == null) { + return false; + } + + if (item.getCategorySet() != null) { + for (String category : item.getCategorySet()) { + if (isEmptyFilesCategoryValue(category)) { + return true; + } + } + } + + String categories = item.getCategories(); + if (categories != null && !categories.isBlank()) { + String[] splitCategories = categories.split(String.valueOf(CategoryTokenizer.SEPARATOR)); + for (String category : splitCategories) { + if (isEmptyFilesCategoryValue(category)) { + return true; + } + } + } + + return false; + } + + private boolean isEmptyFilesCategoryValue(String value) { + if (value == null) { + return false; + } + + String normalized = value.trim().toLowerCase(); + return normalized.equals("empty files") || normalized.contains("empty files"); + } + + private Boolean readCommunicationIsEmpty(IItem item) { + if (item == null) { + return null; + } + + String[] keys = { + ExtraProperties.COMMUNICATION_PREFIX + "isEmpty" + }; + + for (String key : keys) { + Boolean parsed = parseBooleanValue(readFirstValue(item, key)); + if (parsed != null) { + return parsed; + } + } + return null; + } + + private boolean hasSummary(IItem item) { + if (item == null) { + return false; + } + + Object extraValue = item.getExtraAttribute(ExtraProperties.SUMMARY); + if (extraValue instanceof String) { + if (!((String) extraValue).trim().isEmpty()) { + return true; + } + } else if (extraValue instanceof java.util.Collection) { + for (Object value : (java.util.Collection) extraValue) { + if (value != null && !value.toString().trim().isEmpty()) { + return true; + } + } + } else if (extraValue instanceof Object[]) { + for (Object value : (Object[]) extraValue) { + if (value != null && !value.toString().trim().isEmpty()) { + return true; + } + } + } else if (extraValue instanceof String[]) { + for (String value : (String[]) extraValue) { + if (value != null && !value.trim().isEmpty()) { + return true; + } + } + } + + if (item.getMetadata() == null) { + return false; + } + + String[] values = item.getMetadata().getValues(ExtraProperties.SUMMARY); + if (values != null) { + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return true; + } + } + } + + String single = item.getMetadata().get(ExtraProperties.SUMMARY); + return single != null && !single.trim().isEmpty(); + } + + private String readFirstValue(IItem item, String key) { + Object extra = item.getExtraAttribute(key); + if (extra != null) { + if (extra instanceof String) { + return (String) extra; + } + if (extra instanceof Boolean) { + return String.valueOf(extra); + } + if (extra instanceof String[] && ((String[]) extra).length > 0) { + return ((String[]) extra)[0]; + } + return String.valueOf(extra); + } + + if (item.getMetadata() == null) { + return null; + } + + String value = item.getMetadata().get(key); + if (value != null) { + return value; + } + + String[] values = item.getMetadata().getValues(key); + if (values != null && values.length > 0) { + return values[0]; + } + + return null; + } + + private Boolean parseBooleanValue(String raw) { + if (raw == null) { + return null; + } + String normalized = raw.trim().toLowerCase(); + if ("true".equals(normalized) || "1".equals(normalized)) { + return Boolean.TRUE; + } + if ("false".equals(normalized) || "0".equals(normalized)) { + return Boolean.FALSE; + } + return null; + } } \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIConversationPayloadBuilder.java b/iped-app/src/main/java/iped/app/ui/ai/AIConversationPayloadBuilder.java new file mode 100644 index 0000000000..dfaae04250 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/AIConversationPayloadBuilder.java @@ -0,0 +1,152 @@ +package iped.app.ui.ai; + +import iped.data.IItem; +import java.util.List; +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Builds optimized payloads for LLM consumption from context files. + *

+ * This class takes the list of context files (which may contain AI-generated summaries) + * and prepares them for efficient transmission to the LLM backend. It prioritizes + * AI summaries (already compact) over raw HTML, and structures the data as JSON + * for clarity and reduced token usage. + *

+ */ +public class AIConversationPayloadBuilder { + + /** + * Builds a JSON payload from context entries, optimized for LLM consumption. + *

+ * Strategy: + * - If item has AI summary: use it (already condensed by LLM) + * - If no summary: fall back to full HTML content (future optimization) + * - Group by type/source for better context + *

+ * + * @param entries List of context file entries + * @return JSON string ready for backend API + * @throws Exception if payload building fails + */ + public static String buildPayload(List entries) throws Exception { + JSONObject payload = new JSONObject(); + JSONArray conversations = new JSONArray(); + long validCount = entries.stream().filter(ContextFileEntry::isValidForContext).count(); + boolean singleItemMode = validCount <= 1; + + for (ContextFileEntry entry : entries) { + if (!entry.isValidForContext()) { + continue; + } + JSONObject conv = new JSONObject(); + + IItem item = entry.getItem(); + conv.put("itemId", item.getId()); + conv.put("fileName", entry.getFileName()); + conv.put("path", entry.getFullPath()); + conv.put("type", item.getMediaType() != null ? item.getMediaType().toString() : "unknown"); + + // Single item mode: keep only item reference/context, do not send HTML/summary text. + if (singleItemMode) { + conv.put("source", "ITEM_ONLY"); + conv.put("content", ""); + conv.put("contentLength", 0); + conv.put("note", "Single context item: content text intentionally omitted."); + } else { + // Multiple item mode: use summary when available, otherwise keep only item reference. + if (entry.hasSummary()) { + conv.put("source", "SUMMARY"); + conv.put("content", entry.getSummary()); + conv.put("contentLength", entry.getSummary().length()); + } else { + conv.put("source", "ITEM_ONLY"); + conv.put("content", ""); + conv.put("contentLength", 0); + conv.put("note", "Summary not available for this item."); + } + } + + conversations.put(conv); + } + + payload.put("conversations", conversations); + payload.put("totalItems", validCount); + payload.put("singleItemMode", singleItemMode); + payload.put("summariesIncluded", entries.stream() + .filter(ContextFileEntry::isValidForContext) + .filter(ContextFileEntry::hasSummary) + .count()); + + return payload.toString(2); + } + + /** + * Builds a compact text payload (alternative to JSON) for simpler APIs. + * Each conversation is represented as: [FileName] Summary\n\n + */ + public static String buildCompactPayload(List entries) { + StringBuilder sb = new StringBuilder(); + long validCount = entries.stream().filter(ContextFileEntry::isValidForContext).count(); + boolean singleItemMode = validCount <= 1; + + for (ContextFileEntry entry : entries) { + if (!entry.isValidForContext()) { + continue; + } + sb.append("=== ").append(entry.getFileName()).append(" ===\n"); + + if (singleItemMode) { + sb.append("[Single context item: content text omitted]"); + } else if (entry.hasSummary()) { + sb.append(entry.getSummary()); + } else { + sb.append("[No summary available for this item]"); + } + + sb.append("\n\n"); + } + + return sb.toString(); + } + + /** + * Calculates token estimate for the payload (rough approximation: ~4 chars per token). + */ + public static int estimateTokens(String payload) { + return Math.max(1, payload.length() / 4); + } + + /** + * Returns statistics about the payload for debugging/logging. + */ + public static String getPayloadStats(List entries) { + long totalChars = 0; + int validEntries = 0; + int rejectedEntries = 0; + int withSummary = 0; + int withoutSummary = 0; + + for (ContextFileEntry entry : entries) { + if (!entry.isValidForContext()) { + rejectedEntries++; + continue; + } + + validEntries++; + if (entry.hasSummary()) { + withSummary++; + totalChars += entry.getSummary().length(); + } else { + withoutSummary++; + } + } + + int estimatedTokens = estimateTokens("x".repeat((int)totalChars)); + + return String.format( + "Payload Stats: %d valid items, %d rejected, %d with summaries, %d without. Total: %d chars, ~%d tokens estimated.", + validEntries, rejectedEntries, withSummary, withoutSummary, totalChars, estimatedTokens + ); + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java b/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java index 2c5fd7fcd2..0825bf1d05 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java @@ -4,6 +4,8 @@ import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import iped.parsers.standard.StandardParser; +import iped.parsers.whatsapp.WhatsAppParser; /** * A utility service responsible for validating and extracting text content @@ -26,17 +28,16 @@ public class AIWhatsappChatExtractor { * @return true if the file extension or MIME type indicates HTML content; false otherwise. */ public boolean isPotentiallyValidChat(IItem item) { - if (item == null) return false; - - // Check by file extension - String ext = item.getExt(); - if (ext != null && (ext.equalsIgnoreCase("html") || ext.equalsIgnoreCase("htm"))) { + if (item == null) { + return false; + } + + String chatContentType = WhatsAppParser.WHATSAPP_CHAT.toString(); + if (item.getMediaType() != null && chatContentType.equals(item.getMediaType().toString())) { return true; } - - // Fallback check by IPED's recognized Media Type - String mediaType = item.getMediaType() != null ? item.getMediaType().getType() : ""; - return mediaType.contains("text/html"); + + return item.getMetadata() != null && chatContentType.equals(item.getMetadata().get(StandardParser.INDEXER_CONTENT_TYPE)); } /** @@ -49,7 +50,7 @@ public boolean isPotentiallyValidChat(IItem item) { */ public String extractHtml(IItem item) throws Exception { if (!isPotentiallyValidChat(item)) { - throw new IllegalArgumentException("Selected file does not appear to be an HTML chat export."); + throw new IllegalArgumentException("Selected file does not appear to be a WhatsApp chat export."); } // Try-with-resources ensures streams are closed automatically, preventing memory/file handle leaks. diff --git a/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java b/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java index 87e9fa46bc..2e9a873ec6 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java +++ b/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java @@ -15,13 +15,119 @@ public class ContextFileEntry { /** The underlying file item */ private final IItem item; + /** The AI-generated summary of the item (if available) */ + private final String summary; + + /** Whether this entry is valid to be sent to AI context payload */ + private final boolean validForContext; + + /** Validation reason shown in UI when entry is invalid */ + private final String validationReason; + /** * Constructs a new {@code ContextFileEntry} wrapping the given {@link IItem}. * * @param item the item to wrap */ public ContextFileEntry(IItem item) { + this(item, extractSummary(item), true, null); + } + + /** + * Constructs a new {@code ContextFileEntry} wrapping the given {@link IItem} with explicit summary. + * + * @param item the item to wrap + * @param summary the AI-generated summary (can be null) + */ + public ContextFileEntry(IItem item, String summary) { + this(item, summary, true, null); + } + + private ContextFileEntry(IItem item, String summary, boolean validForContext, String validationReason) { this.item = item; + this.summary = summary; + this.validForContext = validForContext; + this.validationReason = validationReason; + } + + /** + * Creates an invalid entry used only for UI feedback. + * + * @param item the item that failed validation + * @param reason the reason shown to the user + * @return invalid context entry + */ + public static ContextFileEntry invalid(IItem item, String reason) { + return new ContextFileEntry(item, extractSummary(item), false, reason); + } + + /** + * Extracts the AI summary from the item's metadata. + */ + private static String extractSummary(IItem item) { + if (item == null) { + return null; + } + + Object extraValue = item.getExtraAttribute(iped.properties.ExtraProperties.SUMMARY); + if (extraValue instanceof String) { + String summary = ((String) extraValue).trim(); + if (!summary.isEmpty()) { + return summary; + } + } else if (extraValue instanceof java.util.Collection) { + StringBuilder sb = new StringBuilder(); + for (Object value : (java.util.Collection) extraValue) { + if (value != null) { + String text = value.toString().trim(); + if (!text.isEmpty()) { + if (sb.length() > 0) { + sb.append("\n"); + } + sb.append(text); + } + } + } + if (sb.length() > 0) { + return sb.toString(); + } + } else if (extraValue instanceof Object[]) { + StringBuilder sb = new StringBuilder(); + for (Object value : (Object[]) extraValue) { + if (value != null) { + String text = value.toString().trim(); + if (!text.isEmpty()) { + if (sb.length() > 0) { + sb.append("\n"); + } + sb.append(text); + } + } + } + if (sb.length() > 0) { + return sb.toString(); + } + } else if (extraValue instanceof String[]) { + String[] summaries = (String[]) extraValue; + if (summaries.length > 0) { + String joined = String.join("\n", summaries).trim(); + if (!joined.isEmpty()) { + return joined; + } + } + } + + if (item.getMetadata() == null) { + return null; + } + + String[] summaries = item.getMetadata().getValues(iped.properties.ExtraProperties.SUMMARY); + if (summaries != null && summaries.length > 0) { + // Join multiple summary parts if any + String joined = String.join("\n", summaries).trim(); + return joined.isEmpty() ? null : joined; + } + return null; } /** @@ -33,6 +139,38 @@ public IItem getItem() { return item; } + /** + * Returns the AI-generated summary if available. + * + * @return the summary, or null if not available + */ + public String getSummary() { + return summary; + } + + /** + * Returns true if this entry has an AI-generated summary. + * + * @return true if summary exists + */ + public boolean hasSummary() { + return summary != null && !summary.trim().isEmpty(); + } + + /** + * Returns true when the entry is valid for AI context payload. + */ + public boolean isValidForContext() { + return validForContext; + } + + /** + * Returns UI validation reason for invalid entries. + */ + public String getValidationReason() { + return validationReason; + } + /** * Returns the displayable file name, or "Unknown File" if {@code null}. * @@ -53,15 +191,30 @@ public String getFullPath() { /** * Returns the label used for display in UI lists. + * Prefers showing the AI summary if available, otherwise shows file name. * * @return display label */ public String getDisplayLabel() { + if (hasSummary()) { + // Truncate to first line and limit length for UI display + String line = summary.split("\n")[0]; + if (line.length() > 80) { + return line.substring(0, 77) + "..."; + } + return line; + } return getFileName(); } @Override public String toString() { + if (!validForContext && validationReason != null && !validationReason.trim().isEmpty()) { + return getFileName() + " - " + validationReason; + } + if (hasSummary()) { + return getDisplayLabel() + " [Summary]"; + } return getDisplayLabel(); } From ece9c4f15a5e4b419b897af0392fd225a17a82f0 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 16 Apr 2026 15:16:15 -0300 Subject: [PATCH 042/143] refactor(http client): use AIInitChatRequest and AIStreamChatRequest DTOs for payload construction --- .../app/ui/ai/backend/AIBackendClient.java | 18 +++---- .../app/ui/ai/backend/AIBackendConfig.java | 2 +- ...hatRequest.java => AIInitChatRequest.java} | 11 ++--- .../ui/ai/backend/AIStreamChatRequest.java | 49 +++++++++++++++++++ 4 files changed, 59 insertions(+), 21 deletions(-) rename iped-app/src/main/java/iped/app/ui/ai/backend/{AIChatRequest.java => AIInitChatRequest.java} (66%) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIStreamChatRequest.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index bcfefd2625..b7b593f66a 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -1,7 +1,6 @@ package iped.app.ui.ai.backend; import com.google.gson.Gson; -import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -72,9 +71,8 @@ public AIBackendClient(AIBackendConfig config) { @Override public String initChat(String chatHtml) throws AIBackendException { try { - // Construct the JSON payload {"chat_content": "..."} - JsonObject payload = new JsonObject(); - payload.addProperty("chat_content", chatHtml); + // Construct the payload using the specified DTO for initChat + AIInitChatRequest payload = new AIInitChatRequest(chatHtml); String jsonBody = gson.toJson(payload); // Build the POST request targeting the initialization endpoint @@ -82,7 +80,7 @@ public String initChat(String chatHtml) throws AIBackendException { .uri(URI.create(config.getBaseUrl() + "/api/init_chat")) .header("Content-Type", "application/json; charset=utf-8") .header("Accept", "application/json") - .header("Authorization", "Bearer " + config.getapiKey()) + .header("Authorization", "Bearer " + config.getApiKey()) .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) .build(); @@ -130,19 +128,15 @@ public String initChat(String chatHtml) throws AIBackendException { @Override public void streamChatResponse(String chatHash, String question, Consumer eventHandler) throws AIBackendException { try { - // Construct the JSON payload for the query - JsonObject payload = new JsonObject(); - payload.addProperty("chat_hash", chatHash); - payload.addProperty("user_question", question); - payload.add("previousmessages", new JsonArray()); // Future multi-turn support - + // Construct the payload for the query using the specified DTO + AIStreamChatRequest payload = new AIStreamChatRequest(chatHash, question); String jsonBody = gson.toJson(payload); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(config.getBaseUrl() + "/api/chat/stream")) .header("Content-Type", "application/json; charset=utf-8") .header("Accept", "application/json") - .header("Authorization", "Bearer " + config.getapiKey()) + .header("Authorization", "Bearer " + config.getApiKey()) .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) .build(); diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java index e289b2bc72..a4cc1a178d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java @@ -26,7 +26,7 @@ public String getBaseUrl() { return baseUrl; } - public String getapiKey() { + public String getApiKey() { return apiKey; } diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIChatRequest.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIInitChatRequest.java similarity index 66% rename from iped-app/src/main/java/iped/app/ui/ai/backend/AIChatRequest.java rename to iped-app/src/main/java/iped/app/ui/ai/backend/AIInitChatRequest.java index da2c851b13..37250a358f 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIChatRequest.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIInitChatRequest.java @@ -7,24 +7,19 @@ * a new stateful chat session. *

*/ -public class AIChatRequest { +public class AIInitChatRequest { /** * The raw text or HTML content to be analyzed by the AI. * Note: The snake_case naming convention is used intentionally here to map - * directly to the Python backend's expected JSON schema without requiring - * additional serialization annotations. + * directly to the Python backend's expected JSON schema */ private final String chat_content; - public AIChatRequest(String chat_content) { + public AIInitChatRequest(String chat_content) { this.chat_content = chat_content; } - /** - * Retrieves the chat content payload. - * @return The string content. - */ public String getChatContent() { return chat_content; } diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIStreamChatRequest.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIStreamChatRequest.java new file mode 100644 index 0000000000..a364410e7f --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIStreamChatRequest.java @@ -0,0 +1,49 @@ +package iped.app.ui.ai.backend; + +import java.util.ArrayList; +import java.util.List; + +/** + * A Data Transfer Object (DTO) representing the conversation request sent to the backend + *

+ * Sent to /api/chat/stream to query a previously initialized chat + *

+ */ +public class AIStreamChatRequest { + + /** + * Note: The naming convention is used intentionally here to map + * directly to the Python backend's expected JSON schema + */ + private final String chat_hash; + private final String user_question; + private final List previousmessages; + + public AIStreamChatRequest(String chatHash, String userQuestion) { + this.chat_hash = chatHash; + this.user_question = userQuestion; + this.previousmessages = new ArrayList<>(); + } + + // Public getter methods + public String getChatHash() { return chat_hash; } + public String getUserQuestion() { return user_question; } + public List getPreviousmessages() { return previousmessages; } + + /** + * Inner class representing the individual chat messages for context history. + */ + public static class AIMessage { + private final String role; // "user" or "assistant" + private final String content; // The message text + + public AIMessage(String role, String content) { + this.role = role; + this.content = content; + } + + // Public getter methods + public String getRole() { return role; } + public String getContent() { return content; } + } +} From 9baf34cbeb83eb3a13d93e03fb25e7f07e03a887 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 16 Apr 2026 16:25:13 -0300 Subject: [PATCH 043/143] feat(llm): add multi turn conversational memory. Add the previous messages of the convo to the HTTP POST so that the llm remembers the previous questions and answers --- .../iped/app/ui/ai/AIChatCoordinator.java | 29 ++++++++++++++++++- .../app/ui/ai/backend/AIBackendClient.java | 6 ++-- .../app/ui/ai/backend/AIBackendService.java | 7 +++-- .../ui/ai/backend/AIStreamChatRequest.java | 5 ++-- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 183c7b7e40..57aae20b4e 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -2,7 +2,9 @@ import iped.app.ui.ai.backend.AIBackendException; import iped.app.ui.ai.backend.AIBackendService; +import iped.app.ui.ai.backend.AIStreamChatRequest; import iped.data.IItem; +import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; @@ -24,6 +26,8 @@ public class AIChatCoordinator { private String currentChatHash = null; private IItem currentContextItem = null; + private final List chatHistory = new ArrayList<>(); + /** * Constructs a new coordinator. * * @param backendService The backend client (can be a Mock or HTTP client) injected @@ -34,6 +38,15 @@ public AIChatCoordinator(AIBackendService backendService) { this.extractor = new AIWhatsappChatExtractor(); } + /** + * Removes tags from llm response to safely attach to chatHistory + * + * @param rawResponse The llm response received + */ + public String cleanThinkingTags(String rawResponse) { + return rawResponse.replaceAll("(?m)^_.*_$\\n?", "").trim(); + } + /** * Executes the complete AI request pipeline: validates the selected file, * uploads the context (if necessary), and streams the AI's response. @@ -70,12 +83,26 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o String html = extractor.extractHtml(item); currentChatHash = backendService.initChat(html); currentContextItem = item; // Update cache + chatHistory.clear(); } // Step B: Stream the response + StringBuilder fullResponse = new StringBuilder(); uiCallback.accept("[Assistant]: "); - backendService.streamChatResponse(currentChatHash, question, uiCallback); + + // Use a lambda to intercept the tokens + backendService.streamChatResponse(currentChatHash, question, chatHistory, token -> { + // Send to the screen + uiCallback.accept(token); + + // Accumulate in memory + fullResponse.append(token); + }); + uiCallback.accept("\n\n"); + String finalAnswer = cleanThinkingTags(fullResponse.toString()); + chatHistory.add(new AIStreamChatRequest.AIMessage("user", question)); + chatHistory.add(new AIStreamChatRequest.AIMessage("assistant", finalAnswer)); } catch (Exception e) { onError.accept("backend error: " + e.getMessage()); diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index b7b593f66a..28d4b9a24d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -13,6 +13,7 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.function.Consumer; +import java.util.List; /** * Concrete implementation of {@link AIBackendService} responsible for handling @@ -116,6 +117,7 @@ public String initChat(String chatHtml) throws AIBackendException { * * @param chatHash Unique identifier of the chat session * @param question User's input question + * @param history The previous messages of this same chat * @param eventHandler Callback invoked for each streamed content chunk * @throws AIBackendException if: *
    @@ -126,10 +128,10 @@ public String initChat(String chatHtml) throws AIBackendException { *
*/ @Override - public void streamChatResponse(String chatHash, String question, Consumer eventHandler) throws AIBackendException { + public void streamChatResponse(String chatHash, String question, List history, Consumer eventHandler) throws AIBackendException { try { // Construct the payload for the query using the specified DTO - AIStreamChatRequest payload = new AIStreamChatRequest(chatHash, question); + AIStreamChatRequest payload = new AIStreamChatRequest(chatHash, question, history); String jsonBody = gson.toJson(payload); HttpRequest request = HttpRequest.newBuilder() diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java index e5f7a1440d..2920613b8c 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java @@ -1,6 +1,7 @@ package iped.app.ui.ai.backend; import java.util.function.Consumer; +import java.util.List; public interface AIBackendService { @@ -16,10 +17,12 @@ public interface AIBackendService { * Queries the LLM regarding a previously initialized chat. * @param chatHash The hash returned from initChat. * @param question The user's question. + * @param history The chat history composed of previous messages * @param eventHandler A callback that receives streamed tokens/status updates from the LLM. * Allows the implementing class to push tokens to the UI as soon as they - * arrive over the network. + * arrive over the network. + * * @throws AIBackendException if the connection fails. */ - void streamChatResponse(String chatHash, String question, Consumer eventHandler) throws AIBackendException; + void streamChatResponse(String chatHash, String question, List history, Consumer eventHandler) throws AIBackendException; } \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIStreamChatRequest.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIStreamChatRequest.java index a364410e7f..5d133d3283 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIStreamChatRequest.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIStreamChatRequest.java @@ -1,6 +1,5 @@ package iped.app.ui.ai.backend; -import java.util.ArrayList; import java.util.List; /** @@ -19,10 +18,10 @@ public class AIStreamChatRequest { private final String user_question; private final List previousmessages; - public AIStreamChatRequest(String chatHash, String userQuestion) { + public AIStreamChatRequest(String chatHash, String userQuestion, List previousmessages) { this.chat_hash = chatHash; this.user_question = userQuestion; - this.previousmessages = new ArrayList<>(); + this.previousmessages = previousmessages; } // Public getter methods From e0606489ef59db9243f2a534322296f4fe092f05 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Fri, 17 Apr 2026 13:45:28 -0300 Subject: [PATCH 044/143] deletes client test as it was a temp file and is no longer needed and updates streamChatResponse method signature to feature the history (even if not used) --- .../ui/ai/backend/AIBackendClientTest.java | 62 ------------------- .../ui/ai/backend/AIBackendMockService.java | 3 +- 2 files changed, 2 insertions(+), 63 deletions(-) delete mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClientTest.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClientTest.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClientTest.java deleted file mode 100644 index f1be9fb1b1..0000000000 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClientTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package iped.app.ui.ai.backend; - -// This is a temporary class used for testing the llm integration with the -// backend server. It sends the POST to the backend server which runs locally -// through docker. -public class AIBackendClientTest { - - public static void main(String[] args) { - System.out.println("Backend Integration Test\n"); - - // Initialize Client - // config will throw an error as there is no valid keycloakToken yet - AIBackendConfig config = new AIBackendConfig("http://localhost:8000", "change-me"); - AIBackendClient client = new AIBackendClient(config); - - // Note: Mock WhatsApp HTML must be formatted EXACTLY how the Python parser expects it - String mockHtml = "" + - "
" + - "
" + - " 21/03/2025 14:30:00" + - " João Silva" + - " Bom dia, qual o preço do pão hoje?" + - "
" + - "
" + - "
" + - "
" + - " 21/03/2025 14:32:00" + - " Carlos" + - " Está caro..." + - "
" + - "
" + - ""; - - try { - // Test init Chat - System.out.println("Testing /api/init_chat..."); - String chatHash = client.initChat(mockHtml); - System.out.println("Server returned Chat Hash: " + chatHash + "\n"); - - // test stream chat - System.out.println("Phase 2: Testing /api/chat/stream..."); - String question = "Qual o nome de quem respondeu a pergunta de João? Quantos anos ele tem?"; - System.out.println("Question: " + question); - System.out.print("Assistant Response: "); - - // Call the stream method. The lambda prints tokens exactly as they arrive. - client.streamChatResponse(chatHash, question, token -> { - System.out.print(token); - System.out.flush(); // Force print to console immediately - }); - - System.out.println("\n\nStream completed successfully"); - - } catch (AIBackendException e) { - System.err.println("\nTest Failed: AI Backend Error"); - e.printStackTrace(); - } catch (Exception e) { - System.err.println("\nTest Failed: Unexpected Java Error"); - e.printStackTrace(); - } - } -} diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java index 99c8940666..2cd6da93fb 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java @@ -1,6 +1,7 @@ package iped.app.ui.ai.backend; import java.util.function.Consumer; +import java.util.List; /** * A mock implementation of the {@link AIBackendService} used for UI testing and local development. @@ -22,7 +23,7 @@ public String initChat(String chatHtml) throws AIBackendException{ } @Override - public void streamChatResponse(String chatHash, String question, Consumer eventHandler) throws AIBackendException { + public void streamChatResponse(String chatHash, String question, List history, Consumer eventHandler) throws AIBackendException { // Simulate the chunked responses (Server-Sent Events) String[] mockTokens = { "Based ", "on ", "the ", "provided ", "file, ", "this ", "is ", "a ", "simulated ", "response." From 4b12690d87e15b568ce21fa3df59bf836e4a044b Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Fri, 17 Apr 2026 14:44:19 -0300 Subject: [PATCH 045/143] feat(multi-chat integration): consolidate multi-chat payload logic into DTO and factory --- .../ui/ai/AIConversationPayloadBuilder.java | 152 ------------------ .../java/iped/app/ui/ai/AIPayloadFactory.java | 69 ++++++++ .../ui/ai/backend/AIInitMultiChatRequest.java | 57 +++++++ 3 files changed, 126 insertions(+), 152 deletions(-) delete mode 100644 iped-app/src/main/java/iped/app/ui/ai/AIConversationPayloadBuilder.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIInitMultiChatRequest.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIConversationPayloadBuilder.java b/iped-app/src/main/java/iped/app/ui/ai/AIConversationPayloadBuilder.java deleted file mode 100644 index dfaae04250..0000000000 --- a/iped-app/src/main/java/iped/app/ui/ai/AIConversationPayloadBuilder.java +++ /dev/null @@ -1,152 +0,0 @@ -package iped.app.ui.ai; - -import iped.data.IItem; -import java.util.List; -import org.json.JSONArray; -import org.json.JSONObject; - -/** - * Builds optimized payloads for LLM consumption from context files. - *

- * This class takes the list of context files (which may contain AI-generated summaries) - * and prepares them for efficient transmission to the LLM backend. It prioritizes - * AI summaries (already compact) over raw HTML, and structures the data as JSON - * for clarity and reduced token usage. - *

- */ -public class AIConversationPayloadBuilder { - - /** - * Builds a JSON payload from context entries, optimized for LLM consumption. - *

- * Strategy: - * - If item has AI summary: use it (already condensed by LLM) - * - If no summary: fall back to full HTML content (future optimization) - * - Group by type/source for better context - *

- * - * @param entries List of context file entries - * @return JSON string ready for backend API - * @throws Exception if payload building fails - */ - public static String buildPayload(List entries) throws Exception { - JSONObject payload = new JSONObject(); - JSONArray conversations = new JSONArray(); - long validCount = entries.stream().filter(ContextFileEntry::isValidForContext).count(); - boolean singleItemMode = validCount <= 1; - - for (ContextFileEntry entry : entries) { - if (!entry.isValidForContext()) { - continue; - } - JSONObject conv = new JSONObject(); - - IItem item = entry.getItem(); - conv.put("itemId", item.getId()); - conv.put("fileName", entry.getFileName()); - conv.put("path", entry.getFullPath()); - conv.put("type", item.getMediaType() != null ? item.getMediaType().toString() : "unknown"); - - // Single item mode: keep only item reference/context, do not send HTML/summary text. - if (singleItemMode) { - conv.put("source", "ITEM_ONLY"); - conv.put("content", ""); - conv.put("contentLength", 0); - conv.put("note", "Single context item: content text intentionally omitted."); - } else { - // Multiple item mode: use summary when available, otherwise keep only item reference. - if (entry.hasSummary()) { - conv.put("source", "SUMMARY"); - conv.put("content", entry.getSummary()); - conv.put("contentLength", entry.getSummary().length()); - } else { - conv.put("source", "ITEM_ONLY"); - conv.put("content", ""); - conv.put("contentLength", 0); - conv.put("note", "Summary not available for this item."); - } - } - - conversations.put(conv); - } - - payload.put("conversations", conversations); - payload.put("totalItems", validCount); - payload.put("singleItemMode", singleItemMode); - payload.put("summariesIncluded", entries.stream() - .filter(ContextFileEntry::isValidForContext) - .filter(ContextFileEntry::hasSummary) - .count()); - - return payload.toString(2); - } - - /** - * Builds a compact text payload (alternative to JSON) for simpler APIs. - * Each conversation is represented as: [FileName] Summary\n\n - */ - public static String buildCompactPayload(List entries) { - StringBuilder sb = new StringBuilder(); - long validCount = entries.stream().filter(ContextFileEntry::isValidForContext).count(); - boolean singleItemMode = validCount <= 1; - - for (ContextFileEntry entry : entries) { - if (!entry.isValidForContext()) { - continue; - } - sb.append("=== ").append(entry.getFileName()).append(" ===\n"); - - if (singleItemMode) { - sb.append("[Single context item: content text omitted]"); - } else if (entry.hasSummary()) { - sb.append(entry.getSummary()); - } else { - sb.append("[No summary available for this item]"); - } - - sb.append("\n\n"); - } - - return sb.toString(); - } - - /** - * Calculates token estimate for the payload (rough approximation: ~4 chars per token). - */ - public static int estimateTokens(String payload) { - return Math.max(1, payload.length() / 4); - } - - /** - * Returns statistics about the payload for debugging/logging. - */ - public static String getPayloadStats(List entries) { - long totalChars = 0; - int validEntries = 0; - int rejectedEntries = 0; - int withSummary = 0; - int withoutSummary = 0; - - for (ContextFileEntry entry : entries) { - if (!entry.isValidForContext()) { - rejectedEntries++; - continue; - } - - validEntries++; - if (entry.hasSummary()) { - withSummary++; - totalChars += entry.getSummary().length(); - } else { - withoutSummary++; - } - } - - int estimatedTokens = estimateTokens("x".repeat((int)totalChars)); - - return String.format( - "Payload Stats: %d valid items, %d rejected, %d with summaries, %d without. Total: %d chars, ~%d tokens estimated.", - validEntries, rejectedEntries, withSummary, withoutSummary, totalChars, estimatedTokens - ); - } -} diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java b/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java new file mode 100644 index 0000000000..1dea824fcf --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java @@ -0,0 +1,69 @@ +package iped.app.ui.ai; + +import iped.app.ui.ai.backend.AIInitMultiChatRequest; +import iped.app.ui.ai.backend.AIInitMultiChatRequest.SummarizedChat; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A Factory utility that translates IPED's internal UI state into + * standard Data Transfer Objects (DTOs) for the network layer. + *

+ * This class acts as a boundary layer. It ensures that the network client + * never needs to know about IPED-specific classes (like {@link ContextFileEntry}), + * and enforces strict validation rules before data is allowed to leave the application + *

+ */ +public class AIPayloadFactory { + + /** + * Builds the Multi-Chat initialization payload by extracting summaries from the UI context. + *

+ * Memory Guardrail: This method aggressively filters out files that do not + * have pre-computed summaries. Sending raw HTML for multiple files would instantly + * exceed the LLM's token context window and crash the backend. + *

+ * * @param entries The raw list of files currently held in the UI's {@link AIContextManager}. + * @return A clean, populated DTO ready for Gson serialization. + * @throws IllegalArgumentException if the provided list yields zero valid summaries, + * meaning a Multi-Chat session cannot be legally formed. + */ + public static AIInitMultiChatRequest buildMultiChatRequest(List entries) { + List summarizedChats = new ArrayList<>(); + + for (ContextFileEntry entry : entries) { + // Skip files that failed validation + if (!entry.isValidForContext()) { + continue; + } + + // In Multi-Chat, summaries are strictly required + // If it doesn't have a summary, skip it to avoid blowing up the LLM context window + if (!entry.hasSummary()) { + continue; + } + + // Map IPED data to backend variables + String chatId = String.valueOf(entry.getItem().getId()); + String chatName = entry.getFileName(); + + // Adapter Pattern: The Python backend expects lists for summaries and IDs + // Since IPED joined them into one block, send a list of size 1 + List summariesList = Collections.singletonList(entry.getSummary()); + List summaryIdsList = Collections.singletonList("summary_1"); + + // Build the inner DTO, representing a single summarized chat + SummarizedChat chatDto = new SummarizedChat(chatId, chatName, summariesList, summaryIdsList); + summarizedChats.add(chatDto); + } + + if (summarizedChats.isEmpty()) { + throw new IllegalArgumentException("No valid file with summary was found for multi-chat analysis"); + } + + // Return the final wrapper DTO + return new AIInitMultiChatRequest(summarizedChats); + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIInitMultiChatRequest.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIInitMultiChatRequest.java new file mode 100644 index 0000000000..b2d51e4533 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIInitMultiChatRequest.java @@ -0,0 +1,57 @@ +package iped.app.ui.ai.backend; + +import java.util.List; + + +/** + * A Data Transfer Object (DTO) representing the initialization payload for a Multi-Chat session. + *

+ * Unlike a standard single chat that uploads raw HTML, the Multi-Chat endpoint + * expects a curated list of pre-computed summaries. This class maps exactly to + * the JSON schema expected by the backend + *

+ */ +public class AIInitMultiChatRequest { + + /** + * The list of chats to be analyzed together. + * Note: The naming convention is used intentionally here to map + * directly to the backend's expected JSON schema + */ + private final List summarized_chats; + + public AIInitMultiChatRequest(List summarizedChats) { + this.summarized_chats = summarizedChats; + } + + public List getSummarizedChats() { return summarized_chats; } + + /** + * Inner class mapping to SummarizedChat Python model + *

+ * Note that SummarizedChat refers to a single chat within summarized_chats. As such, + * each has only one summary and one summary id. Therefore, when constructing a SummarizedChat + * entity, consider that the backend requires a list of summaries and summaryIds, even if + * in reality the chat has only one of each + *

+ */ + public static class SummarizedChat { + private final String chat_id; + private final String chat_name; + private final List summaries; + private final List summary_ids; + + public SummarizedChat(String chatId, String chatName, List summaries, List summaryIds) { + this.chat_id = chatId; + this.chat_name = chatName; + this.summaries = summaries; + this.summary_ids = summaryIds; + } + + // Public getter methods + public String getChatId() { return chat_id; } + public String getChatName() { return chat_name; } + public List getSummaries() { return summaries; } + public List getSummaryIds() { return summary_ids; } + } +} From 7061a5eeb4e9f72f7c2446047b611624571d90e5 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Fri, 17 Apr 2026 15:59:16 -0300 Subject: [PATCH 046/143] feat(multi-chat-integration): implement multi-chat DTOs, payload factory, and HTTP client endpoints --- .../app/ui/ai/backend/AIBackendClient.java | 111 ++++++++++++++++++ .../ui/ai/backend/AIBackendMockService.java | 41 ------- .../app/ui/ai/backend/AIBackendService.java | 33 +++++- .../ai/backend/AIMultiChatStreamRequest.java | 40 +++++++ 4 files changed, 179 insertions(+), 46 deletions(-) delete mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIMultiChatStreamRequest.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index 28d4b9a24d..53bc991657 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -1,6 +1,7 @@ package iped.app.ui.ai.backend; import com.google.gson.Gson; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -14,6 +15,7 @@ import java.time.Duration; import java.util.function.Consumer; import java.util.List; +import java.util.ArrayList; /** * Concrete implementation of {@link AIBackendService} responsible for handling @@ -190,4 +192,113 @@ public void streamChatResponse(String chatHash, String question, List + * This method converts the structured DTO into JSON and parses the resulting + * JSON array response back into a standard Java List of strings + *

+ * @param requestPayload The initialized multi-chat configuration + * @return A list of unique hashes representing the cached sessions on the backend + * @throws AIBackendException if the network fails or the backend returns an error JSON object + */ + @Override + public List initMultiChat(AIInitMultiChatRequest requestPayload) throws AIBackendException { + try { + String jsonBody = gson.toJson(requestPayload); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.getBaseUrl() + "/api/init_multichat_with_summaries")) + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .header("Authorization", "Bearer " + config.getApiKey()) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new AIBackendException("Backend returned HTTP " + response.statusCode() + ": " + response.body()); + } + + JsonObject responseJson = JsonParser.parseString(response.body()).getAsJsonObject(); + if (responseJson.has("error")) { + throw new AIBackendException("Backend Error: " + responseJson.get("error").getAsString()); + } + + // backend returns: {"response": ["hash1", "hash2"]} + List cachedHashes = new ArrayList<>(); + for (JsonElement element : responseJson.getAsJsonArray("response")) { + cachedHashes.add(element.getAsString()); + } + return cachedHashes; + + } catch (Exception e) { + throw new AIBackendException("Failed to initialize multi-chat: " + e.getMessage(), e); + } + } + + /** + * Asynchronously streams a multi-chat response using Server-Sent Events (SSE) + *

+ * This method holds an open HTTP/1.1 InputStream and processes incoming + * {@code data: {JSON}} strings line-by-line. It parses out standard text tokens, + * formats reasoning/status tags into italics, and delegates the result to the UI + *

+ * * @param chatHashes The list of target session hashes + * @param question The prompt + * @param history Conversational memory + * @param eventHandler The UI consumption callback + * @throws AIBackendException if the HTTP stream drops or reports a backend error event + */ + @Override + public void streamMultiChatResponse(List chatHashes, String question, List history, Consumer eventHandler) throws AIBackendException { + try { + AIMultiChatStreamRequest payload = new AIMultiChatStreamRequest(chatHashes, question, history); + String jsonBody = gson.toJson(payload); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.getBaseUrl() + "/api/multichat/stream")) + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .header("Authorization", "Bearer " + config.getApiKey()) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + throw new AIBackendException("Backend returned HTTP " + response.statusCode()); + } + + // The SSE parsing logic is identical to the single-chat stream + try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("data: ")) { + String jsonData = line.substring(6).trim(); + if (jsonData.isEmpty() || jsonData.equals("[DONE]")) continue; + + JsonObject eventObj = JsonParser.parseString(jsonData).getAsJsonObject(); + String type = eventObj.has("type") ? eventObj.get("type").getAsString() : ""; + + if (eventObj.has("content")) { + String content = eventObj.get("content").getAsString(); + + if (type.equals("status") || type.equals("thinking")) { + eventHandler.accept("\n_" + content + "_\n"); + } else if (type.equals("final")) { + eventHandler.accept(content); + } else if (type.equals("error")) { + throw new AIBackendException("Backend Streaming Error: " + content); + } + } + } + } + } + } catch (Exception e) { + throw new AIBackendException("Failed to stream multi-chat response: " + e.getMessage(), e); + } + } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java deleted file mode 100644 index 2cd6da93fb..0000000000 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendMockService.java +++ /dev/null @@ -1,41 +0,0 @@ -package iped.app.ui.ai.backend; - -import java.util.function.Consumer; -import java.util.List; - -/** - * A mock implementation of the {@link AIBackendService} used for UI testing and local development. - *

- * This class simulates the latency and token-by-token streaming behavior of a real - * Large Language Model (LLM) over a network. It allows the frontend application to be - * built, tested, and debugged without requiring a live connection to the backend. - *

- */ -public class AIBackendMockService implements AIBackendService{ - - @Override - public String initChat(String chatHtml) throws AIBackendException{ - // Simulate server processing delay - try {Thread.sleep(1000); } catch (InterruptedException ignored) {} - - // Return fake hash that represents session ID - return "mock_hash_12345abcde"; - } - - @Override - public void streamChatResponse(String chatHash, String question, List history, Consumer eventHandler) throws AIBackendException { - // Simulate the chunked responses (Server-Sent Events) - String[] mockTokens = { - "Based ", "on ", "the ", "provided ", "file, ", "this ", "is ", "a ", "simulated ", "response." - }; - - for (String token : mockTokens) { - try { - Thread.sleep(150); // Delay between tokens to simulate typing/streaming - } catch (InterruptedException ignored) {} - - // Push the generated token back to the UI - eventHandler.accept(token); - } - } -} diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java index 2920613b8c..e65d30c619 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java @@ -4,7 +4,9 @@ import java.util.List; public interface AIBackendService { - + + // --- SINGLE-CHAT ENDPOINTS --- + /** * Sends the extracted HTML content to the backend to initialize the chat. * @param chatHtml The raw WhatsApp HTML string. @@ -19,10 +21,31 @@ public interface AIBackendService { * @param question The user's question. * @param history The chat history composed of previous messages * @param eventHandler A callback that receives streamed tokens/status updates from the LLM. - * Allows the implementing class to push tokens to the UI as soon as they - * arrive over the network. - * - * @throws AIBackendException if the connection fails. + * @throws AIBackendException if the streaming connection fails or returns an error token. */ void streamChatResponse(String chatHash, String question, List history, Consumer eventHandler) throws AIBackendException; + + // --- MULTI-CHAT ENDPOINTS --- + + /** + * Initializes a multi-chat session by uploading a batch of pre-generated summaries + *

+ * This method avoids LLM token-limit errors by explicitly refusing raw HTML and + * relying only on condensed metadata and summaries + *

+ * @param request The structured payload containing the batch of summaries + * @return A list of successfully cached session hashes, one for each summary provided + * @throws AIBackendException if the backend rejects the payload format or is unreachable + */ + List initMultiChat(AIInitMultiChatRequest request) throws AIBackendException; + + /** + * Streams an answer spanning multiple cached conversation summaries + * @param chatHashes The list of session hashes returned by {@link #initMultiChat} + * @param question The user's prompt requiring cross-file analysis + * @param history The chat history providing multi-turn conversational memory + * @param eventHandler A callback that receives streamed tokens/status updates from the LLM + * @throws AIBackendException if the streaming connection fails or returns an error token + */ + void streamMultiChatResponse(List chatHashes, String question, List history, Consumer eventHandler) throws AIBackendException; } \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIMultiChatStreamRequest.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIMultiChatStreamRequest.java new file mode 100644 index 0000000000..656c846e3c --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIMultiChatStreamRequest.java @@ -0,0 +1,40 @@ +package iped.app.ui.ai.backend; + +import java.util.List; + +/** + * A Data Transfer Object (DTO) representing the payload sent to query a Multi-Chat session + *

+ * This request is sent to the {@code /api/multichat/stream} endpoint. It combines + * multiple chat session IDs, the user's current prompt, and the ongoing conversational + * memory into a single JSON structure. + *

+ */ +public class AIMultiChatStreamRequest { + + /** + * {@code chats_hashes} is the correct name in the Python schema, despite the unnecessary 's'. + * Do not rename this to {@code chat_hashes} + */ + private final List chats_hashes; + private final String user_question; + private final List previousmessages; // Reuse the AIMessage class built for single-chat + + /** + * Constructs a new streaming request for a multi-chat session. + * @param chatsHashes The list of session IDs to query against. + * @param userQuestion The prompt text from the user. + * @param previousmessages The accumulated chat history for context. + */ + public AIMultiChatStreamRequest(List chatsHashes, String userQuestion, List previousmessages) { + this.chats_hashes = chatsHashes; + this.user_question = userQuestion; + this.previousmessages = previousmessages; + } + + // Public getter methods + public List getChatsHashes() { return chats_hashes; } + public String getUserQuestion() { return user_question; } + public List getPreviousmessages() { return previousmessages; } +} + From fd38657985a99fa806d6ed53f30e9a93c7a86674 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 23 Apr 2026 15:24:15 -0300 Subject: [PATCH 047/143] feat: add multi-chat routing to coordinator Extend AIChatCoordinator to dynamically route requests based on the context size. It now tracks context state changes, wipes LLM memory when appropriate, and delegates to either the single-chat or multi-chat backend endpoints --- .../config/conf/AISummarizationConfig.txt | 2 +- .../iped/app/ui/ai/AIChatCoordinator.java | 119 +++++++++++------- 2 files changed, 72 insertions(+), 49 deletions(-) diff --git a/iped-app/resources/config/conf/AISummarizationConfig.txt b/iped-app/resources/config/conf/AISummarizationConfig.txt index 03410ef85f..23d93eb2a7 100644 --- a/iped-app/resources/config/conf/AISummarizationConfig.txt +++ b/iped-app/resources/config/conf/AISummarizationConfig.txt @@ -3,7 +3,7 @@ ############################################################################## # IP:PORT of the service/central node used by the AISummarizationTask implementation. -# remoteServiceAddress = 127.0.0.1:8000 +# remoteServiceAddress = 10.61.86.148:30603/summarization # Remote service communication parameters used by AISummarizationTask. busySleepTime = 10.0 diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 57aae20b4e..090fa76cb5 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -2,19 +2,21 @@ import iped.app.ui.ai.backend.AIBackendException; import iped.app.ui.ai.backend.AIBackendService; +import iped.app.ui.ai.backend.AIInitMultiChatRequest; import iped.app.ui.ai.backend.AIStreamChatRequest; import iped.data.IItem; + import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; +import java.util.stream.Collectors; /** * The central orchestrator that coordinates the flow of data between the UI, * the file extraction utilities, and the AI backend service. *

* This class isolates the complexity of threading, session caching, and error handling - * from the presentation layer. It ensures that heavy operations (like extracting files - * and making network calls) are safely routed off the main UI thread. + * from the presentation layer. *

*/ public class AIChatCoordinator { @@ -22,15 +24,15 @@ public class AIChatCoordinator { private final AIBackendService backendService; private final AIWhatsappChatExtractor extractor; - // Cache the hash so we don't re-upload the same chat every time - private String currentChatHash = null; - private IItem currentContextItem = null; + // Track lists to support both single and multi-chat + private List currentChatHashes = new ArrayList<>(); + private List currentContextItemIds = new ArrayList<>(); private final List chatHistory = new ArrayList<>(); /** * Constructs a new coordinator. - * * @param backendService The backend client (can be a Mock or HTTP client) injected + * * @param backendService The backend client injected * for handling actual AI communication. */ public AIChatCoordinator(AIBackendService backendService) { @@ -38,80 +40,101 @@ public AIChatCoordinator(AIBackendService backendService) { this.extractor = new AIWhatsappChatExtractor(); } - /** - * Removes tags from llm response to safely attach to chatHistory - * - * @param rawResponse The llm response received - */ - public String cleanThinkingTags(String rawResponse) { - return rawResponse.replaceAll("(?m)^_.*_$\\n?", "").trim(); - } - /** * Executes the complete AI request pipeline: validates the selected file, * uploads the context (if necessary), and streams the AI's response. - * @param question The text prompt from the user. - * @param uiCallback A callback used to push status updates and streamed text back to the UI. + * @param question The text prompt from the user + * @param uiCallback A callback used to push status updates and streamed text back to the UI * @param onComplete A callback triggered when the entire process finishes (success or fail), - * typically used to re-enable UI buttons. - * @param onError A callback used to push error messages back to the UI. + * typically used to re-enable UI buttons + * @param onError A callback used to push error messages back to the UI */ public void askQuestion(String question, Consumer uiCallback, Runnable onComplete, Consumer onError) { - // Validate Context - List contextFiles = AIContextManager.getInstance().getContextFiles(); - if (contextFiles.isEmpty()) { - onError.accept("Please, add a file to context before asking."); - return; - } - if (contextFiles.size() > 1) { - onError.accept("Only one file is currently supported, remove the extra ones."); + // Fetch only valid entries from the Context Manager + List validEntries = AIContextManager.getInstance().getContextEntriesForUI() + .stream() + .filter(ContextFileEntry::isValidForContext) + .collect(Collectors.toList()); + + if (validEntries.isEmpty()) { + onError.accept("Please, add at least one valid file to context before asking."); return; } - IItem item = contextFiles.get(0); + // Check if the context changed since the last question + List newContextIds = validEntries.stream() + .map(e -> e.getItem().getId()) + .collect(Collectors.toList()); + boolean contextChanged = !newContextIds.equals(currentContextItemIds); // Offload heavy lifting to a background thread new Thread(() -> { try { - // Step A: Extract and Initialize Chat - // If the user uploads a new chat to context, item will refer to - // the new chat and currentContextItem will refer to the last chat - // then, the condition is fulfilled and a new chat is initialized - if (currentChatHash == null || !item.equals(currentContextItem)) { - uiCallback.accept("[System]: Extracting and sending file...\n\n"); - String html = extractor.extractHtml(item); - currentChatHash = backendService.initChat(html); - currentContextItem = item; // Update cache + // Step A: Initialize the Chat (Only if context changed) + if (contextChanged) { + uiCallback.accept("[System]: Initializing context...\n"); chatHistory.clear(); + currentChatHashes.clear(); + + if (validEntries.size() == 1) { + // Single chat + IItem item = validEntries.get(0).getItem(); + String html = extractor.extractHtml(item); + String hash = backendService.initChat(html); + currentChatHashes.add(hash); + } else { + // Multi chat + AIInitMultiChatRequest request = AIPayloadFactory.buildMultiChatRequest(validEntries); + List hashes = backendService.initMultiChat(request); + currentChatHashes.addAll(hashes); + } + + // Update cache state + currentContextItemIds = newContextIds; } // Step B: Stream the response StringBuilder fullResponse = new StringBuilder(); uiCallback.accept("[Assistant]: "); - - // Use a lambda to intercept the tokens - backendService.streamChatResponse(currentChatHash, question, chatHistory, token -> { - // Send to the screen - uiCallback.accept(token); - // Accumulate in memory - fullResponse.append(token); - }); + // Route to the correct streaming endpoint + if (validEntries.size() == 1) { + // Single chat stream + backendService.streamChatResponse(currentChatHashes.get(0), question, chatHistory, token -> { + uiCallback.accept(token); + fullResponse.append(token); + }); + } else { + // Multi chat stream + backendService.streamMultiChatResponse(currentChatHashes, question, chatHistory, token -> { + uiCallback.accept(token); + fullResponse.append(token); + }); + } uiCallback.accept("\n\n"); + + // Step C: Save the turn to history String finalAnswer = cleanThinkingTags(fullResponse.toString()); chatHistory.add(new AIStreamChatRequest.AIMessage("user", question)); chatHistory.add(new AIStreamChatRequest.AIMessage("assistant", finalAnswer)); } catch (Exception e) { - onError.accept("backend error: " + e.getMessage()); + onError.accept("Backend error: " + e.getMessage()); // Invalidate the cache on error so the next attempt tries a fresh upload - currentChatHash = null; + currentContextItemIds.clear(); + currentChatHashes.clear(); } finally { - // Step C: unlock the UI onComplete.run(); } }).start(); } + + /** + * Removes italicized thinking tags before saving to LLM history. + */ + private String cleanThinkingTags(String rawResponse) { + return rawResponse.replaceAll("(?m)^_.*_$\\n?", "").trim(); + } } \ No newline at end of file From c656c9341fc154f8f1e1daa1a78c5885debec3db Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 23 Apr 2026 16:51:14 -0300 Subject: [PATCH 048/143] fix (metadata fetch): fix the summaryId fetching to correctly pull from ExtraProperties.CHUNK_IDS --- .../java/iped/app/ui/ai/AIPayloadFactory.java | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java b/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java index 1dea824fcf..9b0a7c9b8d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java @@ -2,6 +2,8 @@ import iped.app.ui.ai.backend.AIInitMultiChatRequest; import iped.app.ui.ai.backend.AIInitMultiChatRequest.SummarizedChat; +import iped.data.IItem; +import iped.properties.ExtraProperties; import java.util.ArrayList; import java.util.Collections; @@ -51,8 +53,23 @@ public static AIInitMultiChatRequest buildMultiChatRequest(List summariesList = Collections.singletonList(entry.getSummary()); - List summaryIdsList = Collections.singletonList("summary_1"); + List summariesList = extractList(entry.getItem(), ExtraProperties.SUMMARY); + List summaryIdsList = extractList(entry.getItem(), ExtraProperties.CHUNK_IDS); + + // If the extraction fails, fallback to the UI summary + if (summariesList.isEmpty()) { + summariesList = Collections.singletonList(entry.getSummary()); + } + + // Ensure there is an exact 1:1 mapping of IDs to summaries + // If the metadata is missing IDs or the sizes don't match, generate sequential IDs + // to prevent crashing the citation engine + if (summaryIdsList.isEmpty() || summaryIdsList.size() != summariesList.size()) { + summaryIdsList.clear(); // Wipe any mismatched IDs + for (int i = 0; i < summariesList.size(); i++) { + summaryIdsList.add("summary_fallback_" + (i + 1)); + } + } // Build the inner DTO, representing a single summarized chat SummarizedChat chatDto = new SummarizedChat(chatId, chatName, summariesList, summaryIdsList); @@ -66,4 +83,56 @@ public static AIInitMultiChatRequest buildMultiChatRequest(List extractList(IItem item, String key) { + List result = new ArrayList<>(); + if (item == null) return result; + + // Check runtime ExtraAttributes first + Object extraValue = item.getExtraAttribute(key); + if (extraValue instanceof String) { + String str = ((String) extraValue).trim(); + if (!str.isEmpty()) result.add(str); + + } else if (extraValue instanceof java.util.Collection) { + for (Object val : (java.util.Collection) extraValue) { + if (val != null && !val.toString().trim().isEmpty()) { + result.add(val.toString().trim()); + } + } + + } else if (extraValue instanceof Object[]) { + for (Object val : (Object[]) extraValue) { + if (val != null && !val.toString().trim().isEmpty()) { + result.add(val.toString().trim()); + } + } + } + + if (!result.isEmpty()) { + return result; // Found via ExtraAttributes + } + + // Fallback to Lucene stored Metadata + if (item.getMetadata() != null) { + String[] values = item.getMetadata().getValues(key); + if (values != null && values.length > 0) { + for (String val : values) { + if (val != null && !val.trim().isEmpty()) { + result.add(val.trim()); + } + } + } else { + String single = item.getMetadata().get(key); + if (single != null && !single.trim().isEmpty()) { + result.add(single.trim()); + } + } + } + + return result; + } } From bd22147f979342f8454426b81b554bc58bb3d9be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 23 Apr 2026 17:30:06 -0300 Subject: [PATCH 049/143] Enhance AI Assistant Panel with Markdown Support and Refactor Chat Logic Co-authored-by: Copilot --- .../java/iped/app/ui/AIAssistantPanel.java | 327 +++++++++-- .../java/iped/app/ui/ai/AIChatMessage.java | 47 ++ .../java/iped/app/ui/ai/AIChatService.java | 21 + .../iped/app/ui/ai/AIMarkdownRenderer.java | 507 ++++++++++++++++++ .../app/ui/ai/backend/AIBackendClient.java | 96 +++- 5 files changed, 937 insertions(+), 61 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/AIChatMessage.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/AIChatService.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/AIMarkdownRenderer.java diff --git a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java index 2aed2970d7..f9bee6df0d 100644 --- a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java @@ -3,18 +3,25 @@ import java.awt.*; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; -import java.text.SimpleDateFormat; -import java.util.Date; +import java.util.ArrayList; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.swing.*; import javax.swing.border.EmptyBorder; import javax.swing.border.TitledBorder; +import javax.swing.text.BadLocationException; +import javax.swing.text.DefaultStyledDocument; +import javax.swing.text.StyledDocument; +import iped.app.ui.ai.AIChatMessage; +import iped.app.ui.ai.AIChatService; import iped.app.ui.ai.AIContextManager; import iped.app.ui.ai.ContextChangeEvent; import iped.app.ui.ai.ContextChangeListener; import iped.app.ui.ai.ContextFileEntry; +import iped.app.ui.ai.AIMarkdownRenderer; import iped.app.ui.ai.backend.AIBackendClient; import iped.app.ui.ai.backend.AIBackendConfig; import iped.data.IItem; @@ -35,17 +42,28 @@ public class AIAssistantPanel { private static final int VERTICAL_OFFSET = 120; private static final double HEIGHT_PERCENTAGE = 0.8; private static final int PANEL_WIDTH = 550; + private static final int STREAM_APPEND_DELAY_MS = 30; + private static final int AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 24; + private static final Pattern STREAM_PART_PATTERN = Pattern.compile("\\S+|\\s+"); // Main UI components private JDialog dialog; - private JTextArea chatArea; + private JTextPane chatArea; + private JScrollPane chatScrollPane; + private StyledDocument chatDocument; private JTextArea inputArea; private JButton sendButton; private JLabel statusLabel; private JProgressBar progressBar; + private AIMarkdownRenderer markdownRenderer; + private final List chatMessages = new ArrayList<>(); + private final List streamQueue = new ArrayList<>(); + private Timer streamTimer; + private AIChatMessage streamingMessage; + private Runnable streamDrainAction; - // Coordinator (The Controller that handles business logic and threading) - private iped.app.ui.ai.AIChatCoordinator coordinator; + // Service layer that handles business logic and threading + private AIChatService chatService; // Context-related UI components private JPanel contextPanel; @@ -69,11 +87,6 @@ public static AIAssistantPanel getInstance() { * Private constructor: This is the application's wiring hub. */ private AIAssistantPanel() { - // Initialize the Controller. - this.coordinator = new iped.app.ui.ai.AIChatCoordinator( - new AIBackendClient(AIBackendConfig.loadFromSystemProperties()) - ); - createUI(); // Subscribe to the State Manager. @@ -102,16 +115,25 @@ private void createUI() { centerPanel.add(createContextSection(), BorderLayout.NORTH); // Chat display area setup - chatArea = new JTextArea(); + chatArea = new JTextPane(); chatArea.setEditable(false); - chatArea.setLineWrap(true); - chatArea.setWrapStyleWord(true); - chatArea.setFont(new Font("SansSerif", Font.PLAIN, 12)); - chatArea.setBackground(new Color(245, 245, 245)); + chatArea.setBackground(new Color(0xf5, 0xf5, 0xf5)); + chatArea.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + chatDocument = new DefaultStyledDocument(); + chatArea.setDocument(chatDocument); + try { + markdownRenderer = new AIMarkdownRenderer(chatArea); + chatDocument = markdownRenderer.getDocument(); + } catch (Throwable t) { + System.err.println("Failed to initialize markdown renderer: " + t.getMessage()); + t.printStackTrace(); + markdownRenderer = null; + } + refreshChatArea(); - JScrollPane chatScroll = new JScrollPane(chatArea); - chatScroll.setPreferredSize(new Dimension(PANEL_WIDTH, 400)); - centerPanel.add(chatScroll, BorderLayout.CENTER); + chatScrollPane = new JScrollPane(chatArea); + chatScrollPane.setPreferredSize(new Dimension(PANEL_WIDTH, 400)); + centerPanel.add(chatScrollPane, BorderLayout.CENTER); JPanel tasksPanel = createTasksPanel(); centerPanel.add(tasksPanel, BorderLayout.EAST); @@ -292,10 +314,88 @@ public void keyPressed(KeyEvent e) { return bottomPanel; } + private void refreshChatArea() { + JScrollBar verticalBar = chatScrollPane != null ? chatScrollPane.getVerticalScrollBar() : null; + boolean shouldAutoFollow = shouldAutoFollow(verticalBar); + int previousScrollValue = verticalBar != null ? verticalBar.getValue() : -1; + + if (markdownRenderer != null) { + markdownRenderer.renderMessages(chatMessages); + } else { + renderMessagesFallback(); + } + + SwingUtilities.invokeLater(() -> { + if (chatScrollPane == null) { + return; + } + + JScrollBar bar = chatScrollPane.getVerticalScrollBar(); + if (shouldAutoFollow) { + bar.setValue(bar.getMaximum()); + chatArea.setCaretPosition(chatArea.getDocument().getLength()); + return; + } + + if (previousScrollValue >= 0) { + int maxScroll = Math.max(0, bar.getMaximum() - bar.getVisibleAmount()); + bar.setValue(Math.min(previousScrollValue, maxScroll)); + } + }); + } + + private boolean shouldAutoFollow(JScrollBar verticalBar) { + if (verticalBar == null) { + return true; + } + + int distanceToBottom = verticalBar.getMaximum() - (verticalBar.getValue() + verticalBar.getVisibleAmount()); + return distanceToBottom <= AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + } + + private void renderMessagesFallback() { + try { + chatDocument.remove(0, chatDocument.getLength()); + for (AIChatMessage message : chatMessages) { + chatDocument.insertString( + chatDocument.getLength(), + "[" + message.getTime() + "] " + message.getSender() + "\n" + message.getContent() + "\n\n", + null + ); + } + } catch (BadLocationException e) { + System.err.println("Error rendering fallback chat: " + e.getMessage()); + e.printStackTrace(); + } + } + + private boolean ensureChatServiceInitialized() { + if (chatService != null) { + return true; + } + + try { + chatService = new AIChatService(new AIBackendClient(AIBackendConfig.loadFromSystemProperties())); + return true; + } catch (Throwable t) { + addMessage("System Error", "Failed to initialize AI backend: " + t.getMessage(), "error"); + System.err.println("Failed to initialize AI backend: " + t.getMessage()); + t.printStackTrace(); + return false; + } + } + + private void addMessage(String sender, String message, String type) { + chatMessages.add(AIChatMessage.now(sender, message, type)); + refreshChatArea(); + } + + private void addMessage(String sender, String message) { + addMessage(sender, message, "system"); + } + private void positionDialog() { - GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment() - .getDefaultScreenDevice().getDefaultConfiguration(); - Rectangle screenBounds = gc.getBounds(); + Rectangle screenBounds = resolvePreferredScreenBounds(); int height = (int) (screenBounds.height * HEIGHT_PERCENTAGE); dialog.setSize(PANEL_WIDTH + 150, height); @@ -310,44 +410,174 @@ private void positionDialog() { dialog.setLocation(x, y); } + private Rectangle resolvePreferredScreenBounds() { + Window owner = App.get(); + if (owner != null) { + GraphicsConfiguration ownerGc = owner.getGraphicsConfiguration(); + if (ownerGc != null) { + return ownerGc.getBounds(); + } + } + + return GraphicsEnvironment.getLocalGraphicsEnvironment() + .getDefaultScreenDevice().getDefaultConfiguration().getBounds(); + } + + private Rectangle getVirtualScreenBounds() { + Rectangle virtualBounds = new Rectangle(); + for (GraphicsDevice device : GraphicsEnvironment.getLocalGraphicsEnvironment().getScreenDevices()) { + virtualBounds = virtualBounds.union(device.getDefaultConfiguration().getBounds()); + } + return virtualBounds; + } + + private void ensureVisibleOnScreen() { + Rectangle virtualBounds = getVirtualScreenBounds(); + Rectangle currentBounds = dialog.getBounds(); + if (!virtualBounds.intersects(currentBounds)) { + positionDialog(); + } + } + + private void showDialogSafely() { + ensureVisibleOnScreen(); + if (!dialog.isVisible()) { + dialog.setVisible(true); + } + dialog.toFront(); + dialog.requestFocus(); + inputArea.requestFocusInWindow(); + } + + private void beginStreaming(AIChatMessage message) { + streamingMessage = message; + streamQueue.clear(); + streamDrainAction = null; + ensureStreamTimer(); + } + + private void ensureStreamTimer() { + if (streamTimer != null) { + return; + } + + streamTimer = new Timer(STREAM_APPEND_DELAY_MS, e -> onStreamTimerTick()); + } + + private void enqueueStreamToken(String token) { + if (streamingMessage == null || token == null || token.isEmpty()) { + return; + } + + Matcher matcher = STREAM_PART_PATTERN.matcher(token); + while (matcher.find()) { + streamQueue.add(matcher.group()); + } + + if (!streamQueue.isEmpty() && !streamTimer.isRunning()) { + streamTimer.start(); + } + } + + private void onStreamTimerTick() { + if (streamingMessage == null) { + streamTimer.stop(); + return; + } + + if (streamQueue.isEmpty()) { + streamTimer.stop(); + runPendingDrainAction(); + return; + } + + String part = streamQueue.remove(0); + streamingMessage.appendContent(part); + refreshChatArea(); + } + + private void completeStreaming(Runnable onDrained) { + if (streamQueue.isEmpty() && (streamTimer == null || !streamTimer.isRunning())) { + onDrained.run(); + resetStreamingState(); + } else { + streamDrainAction = () -> { + onDrained.run(); + resetStreamingState(); + }; + } + } + + private void runPendingDrainAction() { + if (streamDrainAction == null) { + return; + } + + Runnable action = streamDrainAction; + streamDrainAction = null; + action.run(); + } + + private void resetStreamingState() { + if (streamTimer != null) { + streamTimer.stop(); + } + streamQueue.clear(); + streamDrainAction = null; + streamingMessage = null; + } + /** * The main execution block linking user intent to the background Coordinator. */ private void handleSendAction() { String text = inputArea.getText().trim(); if (!text.isEmpty()) { + if (!ensureChatServiceInitialized()) { + return; + } + // Print user message immediately - addMessage("User", text); + addMessage("You", text, "user"); inputArea.setText(""); // Lock the UI setProcessing(true); - // Call the coordinator - coordinator.askQuestion( + AIChatMessage assistantDraft = AIChatMessage.now("Assistant", "", "assistant"); + chatMessages.add(assistantDraft); + beginStreaming(assistantDraft); + refreshChatArea(); + + // Call the service + chatService.askQuestion( text, - // Callback 1: Stream chunks to the chat area safely on the UI thread + // Callback 1: Append tokens to the live assistant draft (token) -> javax.swing.SwingUtilities.invokeLater(() -> { - chatArea.append(token); - chatArea.setCaretPosition(chatArea.getDocument().getLength()); + enqueueStreamToken(token); + }), + // Callback 2: Keep the completed draft visible and unlock the UI + () -> javax.swing.SwingUtilities.invokeLater(() -> { + completeStreaming(() -> { + if (assistantDraft.getContent().isEmpty()) { + chatMessages.remove(assistantDraft); + refreshChatArea(); + } + setProcessing(false); + }); }), - // Callback 2: Unlock the UI when done - () -> javax.swing.SwingUtilities.invokeLater(() -> setProcessing(false)), // Callback 3: Handle Errors (errorMessage) -> javax.swing.SwingUtilities.invokeLater(() -> { - addMessage("System Error", errorMessage); + resetStreamingState(); + chatMessages.remove(assistantDraft); + refreshChatArea(); + addMessage("System Error", errorMessage, "error"); setProcessing(false); }) ); } } - private void addMessage(String sender, String message) { - String time = new SimpleDateFormat("HH:mm").format(new Date()); - chatArea.append(String.format("[%s] %s: %s\n\n", time, sender, message)); - chatArea.setCaretPosition(chatArea.getDocument().getLength()); - } - /** * Locks or unlocks the input fields and displays the loading bar. */ @@ -359,18 +589,27 @@ private void setProcessing(boolean processing) { } public void toggleVisibility() { - if (dialog.isVisible()) { - dialog.setVisible(false); + Runnable action = () -> { + if (dialog.isVisible()) { + dialog.setVisible(false); + } else { + showDialogSafely(); + } + }; + + if (SwingUtilities.isEventDispatchThread()) { + action.run(); } else { - dialog.setVisible(true); - inputArea.requestFocusInWindow(); + SwingUtilities.invokeLater(action); } } public void showPanel() { - if (!dialog.isVisible()) { - dialog.setVisible(true); + Runnable action = this::showDialogSafely; + if (SwingUtilities.isEventDispatchThread()) { + action.run(); + } else { + SwingUtilities.invokeLater(action); } - inputArea.requestFocusInWindow(); } -} \ No newline at end of file +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatMessage.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatMessage.java new file mode 100644 index 0000000000..e5d41f915a --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatMessage.java @@ -0,0 +1,47 @@ +package iped.app.ui.ai; + +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * Chat message model used by the AI assistant UI. + */ +public class AIChatMessage { + + private final String sender; + private String content; + private final String type; + private final String time; + + private AIChatMessage(String sender, String content, String type, String time) { + this.sender = sender; + this.content = content; + this.type = type; + this.time = time; + } + + public static AIChatMessage now(String sender, String content, String type) { + String time = new SimpleDateFormat("HH:mm").format(new Date()); + return new AIChatMessage(sender, content, type, time); + } + + public String getSender() { + return sender; + } + + public String getContent() { + return content; + } + + public String getType() { + return type; + } + + public String getTime() { + return time; + } + + public void appendContent(String token) { + this.content = this.content + token; + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatService.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatService.java new file mode 100644 index 0000000000..436771fab7 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatService.java @@ -0,0 +1,21 @@ +package iped.app.ui.ai; + +import java.util.function.Consumer; + +import iped.app.ui.ai.backend.AIBackendService; + +/** + * Service layer that encapsulates AI chat workflow orchestration. + */ +public class AIChatService { + + private final AIChatCoordinator coordinator; + + public AIChatService(AIBackendService backendService) { + this.coordinator = new AIChatCoordinator(backendService); + } + + public void askQuestion(String question, Consumer onToken, Runnable onComplete, Consumer onError) { + coordinator.askQuestion(question, onToken, onComplete, onError); + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIMarkdownRenderer.java b/iped-app/src/main/java/iped/app/ui/ai/AIMarkdownRenderer.java new file mode 100644 index 0000000000..1c04638045 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/AIMarkdownRenderer.java @@ -0,0 +1,507 @@ +package iped.app.ui.ai; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.swing.JTextPane; +import javax.swing.text.BadLocationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import javax.swing.text.DefaultStyledDocument; +import javax.swing.text.AttributeSet; +import javax.swing.text.SimpleAttributeSet; +import javax.swing.text.Style; +import javax.swing.text.StyleConstants; +import javax.swing.text.StyledDocument; + +/** + * Renders assistant chat messages with lightweight Markdown support. + */ +public class AIMarkdownRenderer { + + private static final Logger LOGGER = LoggerFactory.getLogger(AIMarkdownRenderer.class); + + private final JTextPane chatArea; + private final StyledDocument chatDocument; + private final Map chatStyles = new HashMap<>(); + + public AIMarkdownRenderer(JTextPane chatArea) { + this.chatArea = chatArea; + this.chatDocument = new DefaultStyledDocument(); + this.chatArea.setDocument(chatDocument); + configureChatStyles(); + } + + /** + * Returns the styled document currently bound to the chat area. + */ + public StyledDocument getDocument() { + return chatDocument; + } + + /** + * Clears the chat and renders all messages in sequence. + */ + public void renderMessages(List messages) { + try { + chatDocument.remove(0, chatDocument.getLength()); + for (AIChatMessage message : messages) { + appendMessageToDocument(message); + } + } catch (BadLocationException e) { + System.err.println("Error rendering chat: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Maps a message type to the corresponding style key. + */ + private String getMessageClass(String type) { + if ("user".equalsIgnoreCase(type)) { + return "user-message"; + } else if ("assistant".equalsIgnoreCase(type)) { + return "assistant-message"; + } else if ("error".equalsIgnoreCase(type)) { + return "error-message"; + } else { + return "system-message"; + } + } + + /** + * Defines all reusable styles used by the chat renderer. + */ + private void configureChatStyles() { + Style base = chatArea.addStyle("base", null); + StyleConstants.setFontFamily(base, "SansSerif"); + StyleConstants.setFontSize(base, 12); + StyleConstants.setForeground(base, new java.awt.Color(0x2f, 0x34, 0x37)); + chatStyles.put("base", base); + + Style sender = chatArea.addStyle("sender", base); + StyleConstants.setBold(sender, true); + StyleConstants.setForeground(sender, new java.awt.Color(0x21, 0x26, 0x2b)); + chatStyles.put("sender", sender); + + Style time = chatArea.addStyle("time", base); + StyleConstants.setFontSize(time, 10); + StyleConstants.setForeground(time, new java.awt.Color(0x7a, 0x81, 0x86)); + chatStyles.put("time", time); + + Style user = chatArea.addStyle("user", base); + StyleConstants.setBackground(user, new java.awt.Color(0xec, 0xef, 0xf1)); + chatStyles.put("user-message", user); + + Style assistant = chatArea.addStyle("assistant", base); + StyleConstants.setBackground(assistant, new java.awt.Color(0xf6, 0xf7, 0xf8)); + chatStyles.put("assistant-message", assistant); + + Style system = chatArea.addStyle("system", base); + StyleConstants.setBackground(system, new java.awt.Color(0xfd, 0xef, 0xef)); + chatStyles.put("system-message", system); + + Style error = chatArea.addStyle("error", base); + StyleConstants.setBackground(error, new java.awt.Color(0xfa, 0xde, 0xde)); + chatStyles.put("error-message", error); + + Style heading = chatArea.addStyle("heading", base); + StyleConstants.setBold(heading, true); + StyleConstants.setFontSize(heading, 14); + chatStyles.put("heading", heading); + + Style bold = chatArea.addStyle("bold", base); + StyleConstants.setBold(bold, true); + chatStyles.put("bold", bold); + + Style italic = chatArea.addStyle("italic", base); + StyleConstants.setItalic(italic, true); + chatStyles.put("italic", italic); + + Style blockquote = chatArea.addStyle("blockquote", base); + StyleConstants.setForeground(blockquote, new java.awt.Color(0x55, 0x55, 0x55)); + chatStyles.put("blockquote", blockquote); + } + + /** + * Renders a single message by composing its container, header and content. + */ + private void appendMessageToDocument(AIChatMessage message) throws BadLocationException { + Style messageStyle = resolveMessageStyle(message.getType()); + SimpleAttributeSet container = createMessageContainer(messageStyle); + + appendMessageHeader(message, container); + appendMessageContent(message.getContent(), container); + appendMessageSeparator(); + } + + /** + * Resolves the style used for a message type, with fallback to base style. + */ + private Style resolveMessageStyle(String type) { + Style messageStyle = chatStyles.get(getMessageClass(type)); + return messageStyle != null ? messageStyle : chatArea.getStyle("base"); + } + + /** + * Creates a paragraph/container attribute set for one rendered message. + */ + private SimpleAttributeSet createMessageContainer(Style messageStyle) { + SimpleAttributeSet container = new SimpleAttributeSet(); + StyleConstants.setBackground(container, StyleConstants.getBackground(messageStyle)); + StyleConstants.setLeftIndent(container, 6f); + StyleConstants.setRightIndent(container, 6f); + return container; + } + + /** + * Writes the message header line: time and sender. + */ + private void appendMessageHeader(AIChatMessage message, SimpleAttributeSet container) throws BadLocationException { + int start = chatDocument.getLength(); + String timeValue = message.getTime() == null ? "--:--" : message.getTime(); + String senderValue = message.getSender() == null ? "Unknown" : message.getSender(); + + chatDocument.insertString(start, "[" + timeValue + "] ", chatArea.getStyle("time")); + chatDocument.insertString(chatDocument.getLength(), senderValue + "\n", chatArea.getStyle("sender")); + applyContainerAttributes(start, container); + } + + /** + * Writes message content using the markdown-aware renderer. + */ + private void appendMessageContent(String content, SimpleAttributeSet container) throws BadLocationException { + appendMarkdown(content == null ? "" : content, container); + } + + /** + * Inserts a blank line to separate message blocks. + */ + private void appendMessageSeparator() throws BadLocationException { + chatDocument.insertString(chatDocument.getLength(), "\n", chatArea.getStyle("base")); + } + + /** + * Renders markdown line by line and applies paragraph/container attributes. + */ + private void appendMarkdown(String markdown, SimpleAttributeSet container) throws BadLocationException { + String normalizedMarkdown = markdown.replace("\r\n", "\n").replace('\r', '\n'); + AttributeSet normalBaseStyle = createBaseStyle(false); + AttributeSet italicBaseStyle = createBaseStyle(true); + String[] lines = normalizedMarkdown.split("\n", -1); + boolean inUnderscoreItalicBlock = false; + + for (String line : lines) { + int lineStart = chatDocument.getLength(); + String lineToRender = line; + String trimmed = lineToRender.trim(); + + int firstNonWhitespace = firstNonWhitespaceIndex(lineToRender); + if (!inUnderscoreItalicBlock && firstNonWhitespace >= 0 && lineToRender.charAt(firstNonWhitespace) == '_') { + debug("open italic block"); + lineToRender = removeCharAt(lineToRender, firstNonWhitespace); + inUnderscoreItalicBlock = true; + } + + int lastNonWhitespace = lastNonWhitespaceIndex(lineToRender); + boolean closesItalicBlock = inUnderscoreItalicBlock && lastNonWhitespace >= 0 + && lineToRender.charAt(lastNonWhitespace) == '_'; + if (closesItalicBlock) { + lineToRender = removeCharAt(lineToRender, lastNonWhitespace); + } + + AttributeSet baseStyle = inUnderscoreItalicBlock ? italicBaseStyle : normalBaseStyle; + trimmed = lineToRender.trim(); + + if (trimmed.isEmpty()) { + debugLineType("blank", lineToRender, inUnderscoreItalicBlock); + chatDocument.insertString(chatDocument.getLength(), "\n", baseStyle); + applyContainerAttributes(lineStart, container); + if (closesItalicBlock) { + debug("close italic block"); + inUnderscoreItalicBlock = false; + } + continue; + } + + if (renderStandaloneItalicLine(lineToRender, baseStyle)) { + debugLineType("standalone-italic", lineToRender, inUnderscoreItalicBlock); + applyContainerAttributes(lineStart, container); + if (closesItalicBlock) { + debug("close italic block"); + inUnderscoreItalicBlock = false; + } + continue; + } + + if (trimmed.startsWith("#")) { + int level = 0; + while (level < trimmed.length() && trimmed.charAt(level) == '#') { + level++; + } + if (level > 0 && level < trimmed.length() && trimmed.charAt(level) == ' ') { + debugLineType("heading", lineToRender, inUnderscoreItalicBlock); + appendStyledLine(trimmed.substring(level + 1), combineStyles(baseStyle, chatArea.getStyle("heading"))); + chatDocument.insertString(chatDocument.getLength(), "\n", chatArea.getStyle("base")); + applyContainerAttributes(lineStart, container); + if (closesItalicBlock) { + debug("close italic block"); + inUnderscoreItalicBlock = false; + } + continue; + } + } + + if (trimmed.startsWith(">")) { + debugLineType("blockquote", lineToRender, inUnderscoreItalicBlock); + appendStyledText(trimmed.substring(1).trim() + "\n", combineStyles(baseStyle, chatArea.getStyle("blockquote"))); + applyContainerAttributes(lineStart, container); + if (closesItalicBlock) { + debug("close italic block"); + inUnderscoreItalicBlock = false; + } + continue; + } + + if (trimmed.startsWith("- ") || trimmed.startsWith("* ") || trimmed.matches("\\d+\\. .*")) { + debugLineType("list", lineToRender, inUnderscoreItalicBlock); + appendStyledText("• ", baseStyle); + appendInlineMarkdown(trimmed.replaceFirst("^(?:- |\\* |\\d+\\. )", ""), baseStyle); + chatDocument.insertString(chatDocument.getLength(), "\n", baseStyle); + applyContainerAttributes(lineStart, container); + if (closesItalicBlock) { + debug("close italic block"); + inUnderscoreItalicBlock = false; + } + continue; + } + + debugLineType("normal", lineToRender, inUnderscoreItalicBlock); + appendInlineMarkdown(lineToRender, baseStyle); + chatDocument.insertString(chatDocument.getLength(), "\n", baseStyle); + applyContainerAttributes(lineStart, container); + if (closesItalicBlock) { + debug("close italic block"); + inUnderscoreItalicBlock = false; + } + } + } + + /** + * Builds a base text style optionally enabling italic. + */ + private AttributeSet createBaseStyle(boolean italic) { + SimpleAttributeSet baseStyle = new SimpleAttributeSet(chatArea.getStyle("base")); + if (italic) { + StyleConstants.setItalic(baseStyle, true); + } + return baseStyle; + } + + /** + * Returns the index of the first non-whitespace character, or -1 if none. + */ + private int firstNonWhitespaceIndex(String text) { + for (int i = 0; i < text.length(); i++) { + if (!Character.isWhitespace(text.charAt(i))) { + return i; + } + } + return -1; + } + + /** + * Returns the index of the last non-whitespace character, or -1 if none. + */ + private int lastNonWhitespaceIndex(String text) { + for (int i = text.length() - 1; i >= 0; i--) { + if (!Character.isWhitespace(text.charAt(i))) { + return i; + } + } + return -1; + } + + /** + * Removes one character at the given index. + */ + private String removeCharAt(String text, int index) { + return text.substring(0, index) + text.substring(index + 1); + } + + /** + * Applies container attributes to both paragraph and character ranges. + */ + private void applyContainerAttributes(int start, SimpleAttributeSet container) throws BadLocationException { + chatDocument.setParagraphAttributes(start, chatDocument.getLength() - start, container, false); + chatDocument.setCharacterAttributes(start, chatDocument.getLength() - start, container, false); + } + + /** + * Renders a full line wrapped with '_' as italic and returns true when applied. + */ + private boolean renderStandaloneItalicLine(String line, AttributeSet baseStyle) throws BadLocationException { + String trimmed = line.trim(); + if (!trimmed.startsWith("_") || !trimmed.endsWith("_") || trimmed.length() < 2) { + return false; + } + + String innerText = trimmed.substring(1, trimmed.length() - 1).trim(); + if (innerText.isEmpty()) { + return false; + } + + chatDocument.insertString(chatDocument.getLength(), innerText, combineStyles(baseStyle, chatArea.getStyle("italic"))); + chatDocument.insertString(chatDocument.getLength(), "\n", baseStyle); + return true; + } + + /** + * Inserts a line of styled text without appending newline automatically. + */ + private void appendStyledLine(String text, AttributeSet style) throws BadLocationException { + chatDocument.insertString(chatDocument.getLength(), text, style); + } + + /** + * Inserts styled text as provided. + */ + private void appendStyledText(String text, AttributeSet style) throws BadLocationException { + chatDocument.insertString(chatDocument.getLength(), text, style); + } + + /** + * Merges multiple attribute sets into a single set. + */ + private AttributeSet combineStyles(AttributeSet... styles) { + SimpleAttributeSet combined = new SimpleAttributeSet(); + for (AttributeSet style : styles) { + if (style != null) { + combined.addAttributes(style); + } + } + return combined; + } + + /** + * Parses inline markdown for bold/italic markers and writes styled segments. + */ + private void appendInlineMarkdown(String text, AttributeSet baseStyle) throws BadLocationException { + int index = 0; + while (index < text.length()) { + int boldStart = text.indexOf("**", index); + int italicStart = findItalicStart(text, index); + + int next = smallestPositive(boldStart, italicStart); + if (next == -1) { + chatDocument.insertString(chatDocument.getLength(), text.substring(index), baseStyle); + return; + } + + if (next > index) { + chatDocument.insertString(chatDocument.getLength(), text.substring(index, next), baseStyle); + } + + if (next == boldStart) { + int end = text.indexOf("**", boldStart + 2); + if (end > boldStart + 2) { + chatDocument.insertString(chatDocument.getLength(), text.substring(boldStart + 2, end), combineStyles(baseStyle, chatArea.getStyle("bold"))); + index = end + 2; + } else { + chatDocument.insertString(chatDocument.getLength(), "**", baseStyle); + index = boldStart + 2; + } + continue; + } + + if (next == italicStart) { + char marker = text.charAt(italicStart); + int end = findItalicEnd(text, italicStart + 1, marker); + if (end > italicStart + 1) { + chatDocument.insertString(chatDocument.getLength(), text.substring(italicStart + 1, end), combineStyles(baseStyle, chatArea.getStyle("italic"))); + index = end + 1; + } else { + chatDocument.insertString(chatDocument.getLength(), String.valueOf(marker), baseStyle); + index = italicStart + 1; + } + } + } + } + + /** + * Logs line classification during markdown debug sessions. + */ + private void debugLineType(String type, String line, boolean italicBlock) { + debug("line type=" + type + " italicBlock=" + italicBlock + " text=[" + preview(line) + "]"); + } + + /** + * Writes a debug message when markdown debug mode is enabled. + */ + private void debug(String message) { + if (isDebugMarkdownEnabled()) { + LOGGER.info("[AIMarkdownRenderer] {}", message); + } + } + + /** + * Returns true when markdown debug logs are enabled via JVM property. + */ + private boolean isDebugMarkdownEnabled() { + return Boolean.parseBoolean(System.getProperty("iped.debugMarkdown", "false")); + } + + /** + * Produces a compact preview string for logs. + */ + private String preview(String text) { + if (text == null) { + return "null"; + } + + String compact = text.replace("\n", "\\n").replace("\r", "\\r"); + return compact.length() > 120 ? compact.substring(0, 120) + "..." : compact; + } + + /** + * Finds the next single '*' marker candidate for italic. + */ + private int findItalicStart(String text, int fromIndex) { + for (int i = fromIndex; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '*' && (i + 1 >= text.length() || text.charAt(i + 1) != '*')) { + return i; + } + } + return -1; + } + + /** + * Finds the matching end marker for italic. + */ + private int findItalicEnd(String text, int fromIndex, char marker) { + for (int i = fromIndex; i < text.length(); i++) { + if (text.charAt(i) == marker) { + return i; + } + } + + return -1; + } + + /** + * Returns the smallest non-negative index among the provided values. + */ + private int smallestPositive(int... values) { + int result = -1; + for (int value : values) { + if (value < 0) { + continue; + } + if (result < 0 || value < result) { + result = value; + } + } + return result; + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index 28d4b9a24d..e746856ab7 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -1,6 +1,7 @@ package iped.app.ui.ai.backend; import com.google.gson.Gson; +import com.google.gson.JsonParseException; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -14,6 +15,8 @@ import java.time.Duration; import java.util.function.Consumer; import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Concrete implementation of {@link AIBackendService} responsible for handling @@ -33,9 +36,12 @@ */ public class AIBackendClient implements AIBackendService { + private static final Logger LOGGER = LoggerFactory.getLogger(AIBackendClient.class); + private final AIBackendConfig config; private final HttpClient httpClient; private final Gson gson; + private final boolean debugEnabled; /** * Constructs a new {@code AIBackendClient}. @@ -46,6 +52,7 @@ public class AIBackendClient implements AIBackendService { public AIBackendClient(AIBackendConfig config) { this.config = config; this.gson = new Gson(); + this.debugEnabled = true; // Create an HTTP client explicitly locked to HTTP/1.1 (required for the backend) this.httpClient = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) @@ -76,6 +83,10 @@ public String initChat(String chatHtml) throws AIBackendException { AIInitChatRequest payload = new AIInitChatRequest(chatHtml); String jsonBody = gson.toJson(payload); + if (debugEnabled) { + LOGGER.info("AI backend init_chat -> {} (payload={} chars)", config.getBaseUrl() + "/api/init_chat", jsonBody.length()); + } + // Build the POST request targeting the initialization endpoint HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(config.getBaseUrl() + "/api/init_chat")) @@ -90,6 +101,9 @@ public String initChat(String chatHtml) throws AIBackendException { // Validate HTTP response if (response.statusCode() != 200) { + if (debugEnabled) { + LOGGER.warn("AI backend init_chat failed with HTTP {}. Body: {}", response.statusCode(), truncate(response.body())); + } throw new AIBackendException("Backend returned HTTP " + response.statusCode() + ": " + response.body()); } @@ -98,6 +112,9 @@ public String initChat(String chatHtml) throws AIBackendException { // Check backend level error if (responseJson.has("error")) { + if (debugEnabled) { + LOGGER.warn("AI backend init_chat returned application error: {}", responseJson.get("error").getAsString()); + } throw new AIBackendException("Backend Error: " + responseJson.get("error").getAsString()); } @@ -134,6 +151,14 @@ public void streamChatResponse(String chatHash, String question, List {} (chatHash={}, payload={} chars, history={})", + config.getBaseUrl() + "/api/chat/stream", + chatHash, + jsonBody.length(), + history != null ? history.size() : 0); + } + HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(config.getBaseUrl() + "/api/chat/stream")) .header("Content-Type", "application/json; charset=utf-8") @@ -146,6 +171,9 @@ public void streamChatResponse(String chatHash, String question, List response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); if (response.statusCode() != 200) { + if (debugEnabled) { + LOGGER.warn("AI backend chat stream failed with HTTP {}", response.statusCode()); + } throw new AIBackendException("Backend returned HTTP " + response.statusCode()); } @@ -155,32 +183,55 @@ public void streamChatResponse(String chatHash, String question, List Date: Fri, 24 Apr 2026 17:34:37 -0300 Subject: [PATCH 050/143] refactor: replace AIChatService with AIChatCoordinator and update message handling Co-authored-by: Copilot --- .../java/iped/app/ui/AIAssistantPanel.java | 40 +++++--- .../java/iped/app/ui/ai/AIChatService.java | 21 ---- .../app/ui/ai/backend/AIBackendClient.java | 96 ++++--------------- 3 files changed, 43 insertions(+), 114 deletions(-) delete mode 100644 iped-app/src/main/java/iped/app/ui/ai/AIChatService.java diff --git a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java index f9bee6df0d..0e9c853672 100644 --- a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java @@ -15,8 +15,8 @@ import javax.swing.text.DefaultStyledDocument; import javax.swing.text.StyledDocument; +import iped.app.ui.ai.AIChatCoordinator; import iped.app.ui.ai.AIChatMessage; -import iped.app.ui.ai.AIChatService; import iped.app.ui.ai.AIContextManager; import iped.app.ui.ai.ContextChangeEvent; import iped.app.ui.ai.ContextChangeListener; @@ -56,14 +56,15 @@ public class AIAssistantPanel { private JLabel statusLabel; private JProgressBar progressBar; private AIMarkdownRenderer markdownRenderer; - private final List chatMessages = new ArrayList<>(); + private final List finalizedMessages = new ArrayList<>(); + private AIChatMessage draftMessage; private final List streamQueue = new ArrayList<>(); private Timer streamTimer; private AIChatMessage streamingMessage; private Runnable streamDrainAction; // Service layer that handles business logic and threading - private AIChatService chatService; + private AIChatCoordinator coordinator; // Context-related UI components private JPanel contextPanel; @@ -312,7 +313,7 @@ public void keyPressed(KeyEvent e) { bottomPanel.add(sendButton, BorderLayout.EAST); return bottomPanel; - } + } private void refreshChatArea() { JScrollBar verticalBar = chatScrollPane != null ? chatScrollPane.getVerticalScrollBar() : null; @@ -320,7 +321,7 @@ private void refreshChatArea() { int previousScrollValue = verticalBar != null ? verticalBar.getValue() : -1; if (markdownRenderer != null) { - markdownRenderer.renderMessages(chatMessages); + markdownRenderer.renderMessages(buildRenderableMessages()); } else { renderMessagesFallback(); } @@ -356,7 +357,7 @@ private boolean shouldAutoFollow(JScrollBar verticalBar) { private void renderMessagesFallback() { try { chatDocument.remove(0, chatDocument.getLength()); - for (AIChatMessage message : chatMessages) { + for (AIChatMessage message : buildRenderableMessages()) { chatDocument.insertString( chatDocument.getLength(), "[" + message.getTime() + "] " + message.getSender() + "\n" + message.getContent() + "\n\n", @@ -370,12 +371,12 @@ private void renderMessagesFallback() { } private boolean ensureChatServiceInitialized() { - if (chatService != null) { + if (coordinator != null) { return true; } try { - chatService = new AIChatService(new AIBackendClient(AIBackendConfig.loadFromSystemProperties())); + coordinator = new AIChatCoordinator(new AIBackendClient(AIBackendConfig.loadFromSystemProperties())); return true; } catch (Throwable t) { addMessage("System Error", "Failed to initialize AI backend: " + t.getMessage(), "error"); @@ -386,10 +387,18 @@ private boolean ensureChatServiceInitialized() { } private void addMessage(String sender, String message, String type) { - chatMessages.add(AIChatMessage.now(sender, message, type)); + finalizedMessages.add(AIChatMessage.now(sender, message, type)); refreshChatArea(); } + private List buildRenderableMessages() { + List renderableMessages = new ArrayList<>(finalizedMessages); + if (draftMessage != null) { + renderableMessages.add(draftMessage); + } + return renderableMessages; + } + private void addMessage(String sender, String message) { addMessage(sender, message, "system"); } @@ -545,12 +554,12 @@ private void handleSendAction() { setProcessing(true); AIChatMessage assistantDraft = AIChatMessage.now("Assistant", "", "assistant"); - chatMessages.add(assistantDraft); + draftMessage = assistantDraft; beginStreaming(assistantDraft); refreshChatArea(); // Call the service - chatService.askQuestion( + coordinator.askQuestion( text, // Callback 1: Append tokens to the live assistant draft (token) -> javax.swing.SwingUtilities.invokeLater(() -> { @@ -560,16 +569,19 @@ private void handleSendAction() { () -> javax.swing.SwingUtilities.invokeLater(() -> { completeStreaming(() -> { if (assistantDraft.getContent().isEmpty()) { - chatMessages.remove(assistantDraft); - refreshChatArea(); + draftMessage = null; + } else { + finalizedMessages.add(assistantDraft); + draftMessage = null; } + refreshChatArea(); setProcessing(false); }); }), // Callback 3: Handle Errors (errorMessage) -> javax.swing.SwingUtilities.invokeLater(() -> { resetStreamingState(); - chatMessages.remove(assistantDraft); + draftMessage = null; refreshChatArea(); addMessage("System Error", errorMessage, "error"); setProcessing(false); diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatService.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatService.java deleted file mode 100644 index 436771fab7..0000000000 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatService.java +++ /dev/null @@ -1,21 +0,0 @@ -package iped.app.ui.ai; - -import java.util.function.Consumer; - -import iped.app.ui.ai.backend.AIBackendService; - -/** - * Service layer that encapsulates AI chat workflow orchestration. - */ -public class AIChatService { - - private final AIChatCoordinator coordinator; - - public AIChatService(AIBackendService backendService) { - this.coordinator = new AIChatCoordinator(backendService); - } - - public void askQuestion(String question, Consumer onToken, Runnable onComplete, Consumer onError) { - coordinator.askQuestion(question, onToken, onComplete, onError); - } -} diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index e746856ab7..28d4b9a24d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -1,7 +1,6 @@ package iped.app.ui.ai.backend; import com.google.gson.Gson; -import com.google.gson.JsonParseException; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -15,8 +14,6 @@ import java.time.Duration; import java.util.function.Consumer; import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Concrete implementation of {@link AIBackendService} responsible for handling @@ -36,12 +33,9 @@ */ public class AIBackendClient implements AIBackendService { - private static final Logger LOGGER = LoggerFactory.getLogger(AIBackendClient.class); - private final AIBackendConfig config; private final HttpClient httpClient; private final Gson gson; - private final boolean debugEnabled; /** * Constructs a new {@code AIBackendClient}. @@ -52,7 +46,6 @@ public class AIBackendClient implements AIBackendService { public AIBackendClient(AIBackendConfig config) { this.config = config; this.gson = new Gson(); - this.debugEnabled = true; // Create an HTTP client explicitly locked to HTTP/1.1 (required for the backend) this.httpClient = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) @@ -83,10 +76,6 @@ public String initChat(String chatHtml) throws AIBackendException { AIInitChatRequest payload = new AIInitChatRequest(chatHtml); String jsonBody = gson.toJson(payload); - if (debugEnabled) { - LOGGER.info("AI backend init_chat -> {} (payload={} chars)", config.getBaseUrl() + "/api/init_chat", jsonBody.length()); - } - // Build the POST request targeting the initialization endpoint HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(config.getBaseUrl() + "/api/init_chat")) @@ -101,9 +90,6 @@ public String initChat(String chatHtml) throws AIBackendException { // Validate HTTP response if (response.statusCode() != 200) { - if (debugEnabled) { - LOGGER.warn("AI backend init_chat failed with HTTP {}. Body: {}", response.statusCode(), truncate(response.body())); - } throw new AIBackendException("Backend returned HTTP " + response.statusCode() + ": " + response.body()); } @@ -112,9 +98,6 @@ public String initChat(String chatHtml) throws AIBackendException { // Check backend level error if (responseJson.has("error")) { - if (debugEnabled) { - LOGGER.warn("AI backend init_chat returned application error: {}", responseJson.get("error").getAsString()); - } throw new AIBackendException("Backend Error: " + responseJson.get("error").getAsString()); } @@ -151,14 +134,6 @@ public void streamChatResponse(String chatHash, String question, List {} (chatHash={}, payload={} chars, history={})", - config.getBaseUrl() + "/api/chat/stream", - chatHash, - jsonBody.length(), - history != null ? history.size() : 0); - } - HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(config.getBaseUrl() + "/api/chat/stream")) .header("Content-Type", "application/json; charset=utf-8") @@ -171,9 +146,6 @@ public void streamChatResponse(String chatHash, String question, List response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); if (response.statusCode() != 200) { - if (debugEnabled) { - LOGGER.warn("AI backend chat stream failed with HTTP {}", response.statusCode()); - } throw new AIBackendException("Backend returned HTTP " + response.statusCode()); } @@ -183,55 +155,32 @@ public void streamChatResponse(String chatHash, String question, List Date: Mon, 27 Apr 2026 14:59:54 -0300 Subject: [PATCH 051/143] feat(markdown): enhance message handling with draft rendering and commit/discard functionality Co-authored-by: Copilot --- .../java/iped/app/ui/AIAssistantPanel.java | 81 +++++++++++++++++-- .../iped/app/ui/ai/AIMarkdownRenderer.java | 57 +++++++++++++ 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java index 0e9c853672..7846efc732 100644 --- a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java @@ -387,8 +387,14 @@ private boolean ensureChatServiceInitialized() { } private void addMessage(String sender, String message, String type) { - finalizedMessages.add(AIChatMessage.now(sender, message, type)); - refreshChatArea(); + AIChatMessage chatMessage = AIChatMessage.now(sender, message, type); + finalizedMessages.add(chatMessage); + + if (markdownRenderer != null) { + appendFinalizedMessage(chatMessage); + } else { + refreshChatArea(); + } } private List buildRenderableMessages() { @@ -502,7 +508,7 @@ private void onStreamTimerTick() { String part = streamQueue.remove(0); streamingMessage.appendContent(part); - refreshChatArea(); + renderDraftMessage(); } private void completeStreaming(Runnable onDrained) { @@ -556,7 +562,7 @@ private void handleSendAction() { AIChatMessage assistantDraft = AIChatMessage.now("Assistant", "", "assistant"); draftMessage = assistantDraft; beginStreaming(assistantDraft); - refreshChatArea(); + renderDraftMessage(); // Call the service coordinator.askQuestion( @@ -569,20 +575,27 @@ private void handleSendAction() { () -> javax.swing.SwingUtilities.invokeLater(() -> { completeStreaming(() -> { if (assistantDraft.getContent().isEmpty()) { + if (markdownRenderer != null) { + markdownRenderer.discardDraft(); + } draftMessage = null; } else { finalizedMessages.add(assistantDraft); + if (markdownRenderer != null) { + markdownRenderer.commitDraft(); + } draftMessage = null; } - refreshChatArea(); setProcessing(false); }); }), // Callback 3: Handle Errors (errorMessage) -> javax.swing.SwingUtilities.invokeLater(() -> { + if (markdownRenderer != null) { + markdownRenderer.discardDraft(); + } resetStreamingState(); draftMessage = null; - refreshChatArea(); addMessage("System Error", errorMessage, "error"); setProcessing(false); }) @@ -600,6 +613,62 @@ private void setProcessing(boolean processing) { dialog.setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) : Cursor.getDefaultCursor()); } + private void appendFinalizedMessage(AIChatMessage message) { + if (chatScrollPane == null) { + refreshChatArea(); + return; + } + + JScrollBar verticalBar = chatScrollPane.getVerticalScrollBar(); + boolean shouldAutoFollow = shouldAutoFollow(verticalBar); + int previousScrollValue = verticalBar != null ? verticalBar.getValue() : -1; + + markdownRenderer.appendMessage(message); + restoreScrollAfterIncrementalUpdate(shouldAutoFollow, previousScrollValue); + } + + private void renderDraftMessage() { + if (draftMessage == null) { + return; + } + + if (chatScrollPane == null || markdownRenderer == null) { + refreshChatArea(); + return; + } + + JScrollBar verticalBar = chatScrollPane.getVerticalScrollBar(); + boolean shouldAutoFollow = shouldAutoFollow(verticalBar); + int previousScrollValue = verticalBar != null ? verticalBar.getValue() : -1; + + markdownRenderer.renderDraft(draftMessage); + restoreScrollAfterIncrementalUpdate(shouldAutoFollow, previousScrollValue); + } + + private void restoreScrollAfterIncrementalUpdate(boolean shouldAutoFollow, int previousScrollValue) { + if (chatScrollPane == null) { + return; + } + + SwingUtilities.invokeLater(() -> { + if (chatScrollPane == null) { + return; + } + + JScrollBar bar = chatScrollPane.getVerticalScrollBar(); + if (shouldAutoFollow) { + bar.setValue(bar.getMaximum()); + chatArea.setCaretPosition(chatArea.getDocument().getLength()); + return; + } + + if (previousScrollValue >= 0) { + int maxScroll = Math.max(0, bar.getMaximum() - bar.getVisibleAmount()); + bar.setValue(Math.min(previousScrollValue, maxScroll)); + } + }); + } + public void toggleVisibility() { Runnable action = () -> { if (dialog.isVisible()) { diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIMarkdownRenderer.java b/iped-app/src/main/java/iped/app/ui/ai/AIMarkdownRenderer.java index 1c04638045..5849ec7c68 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIMarkdownRenderer.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIMarkdownRenderer.java @@ -24,6 +24,7 @@ public class AIMarkdownRenderer { private final JTextPane chatArea; private final StyledDocument chatDocument; private final Map chatStyles = new HashMap<>(); + private int draftStartOffset = -1; public AIMarkdownRenderer(JTextPane chatArea) { this.chatArea = chatArea; @@ -44,6 +45,7 @@ public StyledDocument getDocument() { */ public void renderMessages(List messages) { try { + draftStartOffset = -1; chatDocument.remove(0, chatDocument.getLength()); for (AIChatMessage message : messages) { appendMessageToDocument(message); @@ -54,6 +56,61 @@ public void renderMessages(List messages) { } } + /** + * Appends a finalized message to the end of the current document. + */ + public void appendMessage(AIChatMessage message) { + try { + appendMessageToDocument(message); + } catch (BadLocationException e) { + System.err.println("Error appending chat message: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Replaces only the streaming draft at the end of the document. + */ + public void renderDraft(AIChatMessage message) { + try { + if (draftStartOffset < 0) { + draftStartOffset = chatDocument.getLength(); + } else { + chatDocument.remove(draftStartOffset, chatDocument.getLength() - draftStartOffset); + } + + appendMessageToDocument(message); + } catch (BadLocationException e) { + System.err.println("Error rendering draft chat: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Marks the current draft as committed, keeping the rendered content in place. + */ + public void commitDraft() { + draftStartOffset = -1; + } + + /** + * Removes the current draft from the document. + */ + public void discardDraft() { + if (draftStartOffset < 0) { + return; + } + + try { + chatDocument.remove(draftStartOffset, chatDocument.getLength() - draftStartOffset); + } catch (BadLocationException e) { + System.err.println("Error discarding draft chat: " + e.getMessage()); + e.printStackTrace(); + } finally { + draftStartOffset = -1; + } + } + /** * Maps a message type to the corresponding style key. */ From c7ee243b2ae362f385c6520891786b676f56e22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Mon, 27 Apr 2026 17:26:03 -0300 Subject: [PATCH 052/143] refactor(context): update display logic to use file name instead of AI summary --- .../java/iped/app/ui/AIAssistantPanel.java | 2 +- .../java/iped/app/ui/ai/ContextFileEntry.java | 22 ++----------------- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java index 7846efc732..74200391d6 100644 --- a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java @@ -185,7 +185,7 @@ private JPanel createContextSection() { if (value instanceof ContextFileEntry) { ContextFileEntry entry = (ContextFileEntry) value; if (entry.isValidForContext()) { - label.setText(entry.getDisplayLabel()); + label.setText(entry.getFileName()); label.setToolTipText(entry.getFullPath()); } else { String reason = entry.getValidationReason() != null ? entry.getValidationReason() : "Rejected item."; diff --git a/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java b/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java index 2e9a873ec6..30041468a2 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java +++ b/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java @@ -189,33 +189,15 @@ public String getFullPath() { return item.getPath() != null ? item.getPath() : ""; } - /** - * Returns the label used for display in UI lists. - * Prefers showing the AI summary if available, otherwise shows file name. - * - * @return display label - */ - public String getDisplayLabel() { - if (hasSummary()) { - // Truncate to first line and limit length for UI display - String line = summary.split("\n")[0]; - if (line.length() > 80) { - return line.substring(0, 77) + "..."; - } - return line; - } - return getFileName(); - } - @Override public String toString() { if (!validForContext && validationReason != null && !validationReason.trim().isEmpty()) { return getFileName() + " - " + validationReason; } if (hasSummary()) { - return getDisplayLabel() + " [Summary]"; + return getFileName() + " [Summary]"; } - return getDisplayLabel(); + return getFileName(); } @Override From 2c8b06054e5de31e94b813a717d37085a100f6ae Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 28 Apr 2026 13:58:23 -0300 Subject: [PATCH 053/143] fix: chat identifier changed from the item id to its md5 hash. LLM correctly refers to chats in the format <> --- .../java/iped/app/ui/ai/AIPayloadFactory.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java b/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java index 9b0a7c9b8d..fd15b17d2e 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java @@ -48,13 +48,22 @@ public static AIInitMultiChatRequest buildMultiChatRequest(List summariesList = extractList(entry.getItem(), ExtraProperties.SUMMARY); - List summaryIdsList = extractList(entry.getItem(), ExtraProperties.CHUNK_IDS); + List summariesList = extractList(item, ExtraProperties.SUMMARY); + List summaryIdsList = extractList(item, ExtraProperties.CHUNK_IDS); // If the extraction fails, fallback to the UI summary if (summariesList.isEmpty()) { From 2e361144f93ee050857cb03f019b18171a489ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Tue, 28 Apr 2026 14:07:25 -0300 Subject: [PATCH 054/143] refactor(context): update context list to support summary rows and improve UI display --- .../java/iped/app/ui/AIAssistantPanel.java | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java index 74200391d6..410311137c 100644 --- a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java @@ -68,12 +68,26 @@ public class AIAssistantPanel { // Context-related UI components private JPanel contextPanel; - private JList contextList; - private DefaultListModel contextListModel; + private JList contextList; + private DefaultListModel contextListModel; private JLabel contextEmptyLabel; private JButton clearContextButton; private TitledBorder contextBorder; + private static final class ContextSummaryRow { + private final String text; + + private ContextSummaryRow(String text) { + this.text = text; + } + + @Override + public String toString() { + return text; + } + } + + // Singleton instance ensures only one floating panel exists at a time private static AIAssistantPanel instance; @@ -182,6 +196,16 @@ private JPanel createContextSection() { contextList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { JLabel label = (JLabel) new DefaultListCellRenderer() .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + + if (value instanceof ContextSummaryRow) { + ContextSummaryRow summary = (ContextSummaryRow) value; + label.setText(summary.toString()); + label.setForeground(Color.DARK_GRAY); + label.setFont(label.getFont().deriveFont(Font.ITALIC)); + label.setToolTipText(summary.toString()); + return label; + } + if (value instanceof ContextFileEntry) { ContextFileEntry entry = (ContextFileEntry) value; if (entry.isValidForContext()) { @@ -194,6 +218,7 @@ private JPanel createContextSection() { label.setForeground(new Color(180, 0, 0)); } } + return label; }); @@ -246,8 +271,16 @@ private void refreshContextUI() { contextEmptyLabel.setVisible(false); contextList.setVisible(true); clearContextButton.setEnabled(true); - for (ContextFileEntry entry : entries) { - contextListModel.addElement(entry); + + int visibleCount = Math.min(5, entries.size()); + for (int i = 0; i < visibleCount; i++) { + contextListModel.addElement(entries.get(i)); + } + + if (entries.size() > 5) { + int hiddenCount = entries.size() - 5; + String summaryText = "+ " + hiddenCount + " more items (" + validFiles.size() + " valid, " + invalidCount + " rejected)"; + contextListModel.addElement(new ContextSummaryRow(summaryText)); } } @@ -256,6 +289,7 @@ private void refreshContextUI() { } else { contextBorder.setTitle("Added Context (" + validFiles.size() + " valid)"); } + contextPanel.repaint(); } From f7649b815a60bbbcd0b9bc8ced2502ce69054e70 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 28 Apr 2026 15:35:37 -0300 Subject: [PATCH 055/143] refactor: move ai-related files to new subfolders for better project organization. Update import paths on the relevant files --- iped-app/src/main/java/iped/app/ui/App.java | 5 +++-- iped-app/src/main/java/iped/app/ui/MenuClass.java | 3 ++- .../main/java/iped/app/ui/UICaseDataLoader.java | 2 +- .../java/iped/app/ui/ai/AIChatCoordinator.java | 5 ++++- .../app/ui/ai/{ => context}/AIContextManager.java | 3 ++- .../ui/ai/{ => context}/ContextChangeEvent.java | 2 +- .../ai/{ => context}/ContextChangeListener.java | 2 +- .../app/ui/ai/{ => filters}/AIFiltersLoader.java | 2 +- .../ai/{ => filters}/AIFiltersLocalization.java | 2 +- .../{ => filters}/AIFiltersTreeCellRenderer.java | 2 +- .../ai/{ => filters}/AIFiltersTreeListener.java | 2 +- .../ui/ai/{ => filters}/AIFiltersTreeModel.java | 2 +- .../iped/app/ui/ai/{ => model}/AIChatMessage.java | 2 +- .../app/ui/ai/{ => model}/ContextFileEntry.java | 2 +- .../app/ui/ai/{ => util}/AIPayloadFactory.java | 3 ++- .../ui/ai/{ => util}/AIWhatsappChatExtractor.java | 2 +- .../app/ui/{ => ai/view}/AIAssistantPanel.java | 15 ++++++++------- .../app/ui/ai/{ => view}/AIMarkdownRenderer.java | 4 +++- 18 files changed, 35 insertions(+), 25 deletions(-) rename iped-app/src/main/java/iped/app/ui/ai/{ => context}/AIContextManager.java (99%) rename iped-app/src/main/java/iped/app/ui/ai/{ => context}/ContextChangeEvent.java (97%) rename iped-app/src/main/java/iped/app/ui/ai/{ => context}/ContextChangeListener.java (94%) rename iped-app/src/main/java/iped/app/ui/ai/{ => filters}/AIFiltersLoader.java (99%) rename iped-app/src/main/java/iped/app/ui/ai/{ => filters}/AIFiltersLocalization.java (96%) rename iped-app/src/main/java/iped/app/ui/ai/{ => filters}/AIFiltersTreeCellRenderer.java (98%) rename iped-app/src/main/java/iped/app/ui/ai/{ => filters}/AIFiltersTreeListener.java (99%) rename iped-app/src/main/java/iped/app/ui/ai/{ => filters}/AIFiltersTreeModel.java (97%) rename iped-app/src/main/java/iped/app/ui/ai/{ => model}/AIChatMessage.java (97%) rename iped-app/src/main/java/iped/app/ui/ai/{ => model}/ContextFileEntry.java (99%) rename iped-app/src/main/java/iped/app/ui/ai/{ => util}/AIPayloadFactory.java (98%) rename iped-app/src/main/java/iped/app/ui/ai/{ => util}/AIWhatsappChatExtractor.java (99%) rename iped-app/src/main/java/iped/app/ui/{ => ai/view}/AIAssistantPanel.java (98%) rename iped-app/src/main/java/iped/app/ui/ai/{ => view}/AIMarkdownRenderer.java (99%) diff --git a/iped-app/src/main/java/iped/app/ui/App.java b/iped-app/src/main/java/iped/app/ui/App.java index 815e80ff05..bb15cbe953 100644 --- a/iped-app/src/main/java/iped/app/ui/App.java +++ b/iped-app/src/main/java/iped/app/ui/App.java @@ -122,8 +122,9 @@ import iped.app.config.XMLResultSetViewerConfiguration; import iped.app.graph.AppGraphAnalytics; import iped.app.graph.FilterSelectedEdges; -import iped.app.ui.ai.AIFiltersTreeCellRenderer; -import iped.app.ui.ai.AIFiltersTreeListener; +import iped.app.ui.ai.view.AIAssistantPanel; +import iped.app.ui.ai.filters.AIFiltersTreeCellRenderer; +import iped.app.ui.ai.filters.AIFiltersTreeListener; import iped.app.ui.bookmarks.BookmarkIcon; import iped.app.ui.bookmarks.BookmarkTreeCellRenderer; import iped.app.ui.columns.ColumnsManager; diff --git a/iped-app/src/main/java/iped/app/ui/MenuClass.java b/iped-app/src/main/java/iped/app/ui/MenuClass.java index cc067259ef..bded030efa 100644 --- a/iped-app/src/main/java/iped/app/ui/MenuClass.java +++ b/iped-app/src/main/java/iped/app/ui/MenuClass.java @@ -33,7 +33,8 @@ import javax.swing.KeyStroke; import javax.swing.SwingUtilities; -import iped.app.ui.ai.AIContextManager; +import iped.app.ui.ai.context.AIContextManager; +import iped.app.ui.ai.view.AIAssistantPanel; import iped.app.ui.themes.Theme; import iped.app.ui.themes.ThemeManager; import iped.data.IItem; diff --git a/iped-app/src/main/java/iped/app/ui/UICaseDataLoader.java b/iped-app/src/main/java/iped/app/ui/UICaseDataLoader.java index 428db9cd18..f5342f46f7 100644 --- a/iped-app/src/main/java/iped/app/ui/UICaseDataLoader.java +++ b/iped-app/src/main/java/iped/app/ui/UICaseDataLoader.java @@ -30,7 +30,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import iped.app.ui.ai.AIFiltersLoader; +import iped.app.ui.ai.filters.AIFiltersLoader; import iped.app.ui.columns.ColumnsManagerUI; import iped.engine.config.ConfigurationManager; import iped.engine.core.EvidenceStatus; diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 090fa76cb5..c24f276147 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -1,9 +1,12 @@ package iped.app.ui.ai; -import iped.app.ui.ai.backend.AIBackendException; import iped.app.ui.ai.backend.AIBackendService; import iped.app.ui.ai.backend.AIInitMultiChatRequest; import iped.app.ui.ai.backend.AIStreamChatRequest; +import iped.app.ui.ai.util.AIWhatsappChatExtractor; +import iped.app.ui.ai.util.AIPayloadFactory; +import iped.app.ui.ai.model.ContextFileEntry; +import iped.app.ui.ai.context.AIContextManager; import iped.data.IItem; import java.util.ArrayList; diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIContextManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java similarity index 99% rename from iped-app/src/main/java/iped/app/ui/ai/AIContextManager.java rename to iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java index 3094a3c3c1..84ca0ea3c2 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIContextManager.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.context; import java.util.ArrayList; import java.util.List; @@ -6,6 +6,7 @@ import javax.swing.SwingUtilities; import javax.swing.event.EventListenerList; +import iped.app.ui.ai.model.ContextFileEntry; import iped.data.IItem; import iped.engine.lucene.analysis.CategoryTokenizer; import iped.parsers.standard.StandardParser; diff --git a/iped-app/src/main/java/iped/app/ui/ai/ContextChangeEvent.java b/iped-app/src/main/java/iped/app/ui/ai/context/ContextChangeEvent.java similarity index 97% rename from iped-app/src/main/java/iped/app/ui/ai/ContextChangeEvent.java rename to iped-app/src/main/java/iped/app/ui/ai/context/ContextChangeEvent.java index 89dd75b8c1..8cb5bf3628 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/ContextChangeEvent.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/ContextChangeEvent.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.context; import java.util.EventObject; diff --git a/iped-app/src/main/java/iped/app/ui/ai/ContextChangeListener.java b/iped-app/src/main/java/iped/app/ui/ai/context/ContextChangeListener.java similarity index 94% rename from iped-app/src/main/java/iped/app/ui/ai/ContextChangeListener.java rename to iped-app/src/main/java/iped/app/ui/ai/context/ContextChangeListener.java index 6dcab386b9..4f36dcac30 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/ContextChangeListener.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/ContextChangeListener.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.context; import java.util.EventListener; diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersLoader.java b/iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersLoader.java similarity index 99% rename from iped-app/src/main/java/iped/app/ui/ai/AIFiltersLoader.java rename to iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersLoader.java index 0814f426d0..25d5a09277 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersLoader.java +++ b/iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersLoader.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.filters; import java.io.IOException; import java.text.Collator; diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersLocalization.java b/iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersLocalization.java similarity index 96% rename from iped-app/src/main/java/iped/app/ui/ai/AIFiltersLocalization.java rename to iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersLocalization.java index cda432b35b..1847869963 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersLocalization.java +++ b/iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersLocalization.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.filters; import java.util.MissingResourceException; import java.util.ResourceBundle; diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersTreeCellRenderer.java b/iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersTreeCellRenderer.java similarity index 98% rename from iped-app/src/main/java/iped/app/ui/ai/AIFiltersTreeCellRenderer.java rename to iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersTreeCellRenderer.java index 8bff4c17a0..f3ef50e8eb 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersTreeCellRenderer.java +++ b/iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersTreeCellRenderer.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.filters; import java.awt.Color; import java.awt.Component; diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersTreeListener.java b/iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersTreeListener.java similarity index 99% rename from iped-app/src/main/java/iped/app/ui/ai/AIFiltersTreeListener.java rename to iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersTreeListener.java index f0257c4d75..43e0349e8d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersTreeListener.java +++ b/iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersTreeListener.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.filters; import java.util.ArrayList; import java.util.HashSet; diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersTreeModel.java b/iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersTreeModel.java similarity index 97% rename from iped-app/src/main/java/iped/app/ui/ai/AIFiltersTreeModel.java rename to iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersTreeModel.java index aa13e40216..dfbdf3f544 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIFiltersTreeModel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/filters/AIFiltersTreeModel.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.filters; import java.util.ArrayList; import java.util.List; diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatMessage.java b/iped-app/src/main/java/iped/app/ui/ai/model/AIChatMessage.java similarity index 97% rename from iped-app/src/main/java/iped/app/ui/ai/AIChatMessage.java rename to iped-app/src/main/java/iped/app/ui/ai/model/AIChatMessage.java index e5d41f915a..4794f4e273 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatMessage.java +++ b/iped-app/src/main/java/iped/app/ui/ai/model/AIChatMessage.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.model; import java.text.SimpleDateFormat; import java.util.Date; diff --git a/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java b/iped-app/src/main/java/iped/app/ui/ai/model/ContextFileEntry.java similarity index 99% rename from iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java rename to iped-app/src/main/java/iped/app/ui/ai/model/ContextFileEntry.java index 2e9a873ec6..33a9ed392f 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/ContextFileEntry.java +++ b/iped-app/src/main/java/iped/app/ui/ai/model/ContextFileEntry.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.model; import iped.data.IItem; diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java b/iped-app/src/main/java/iped/app/ui/ai/util/AIPayloadFactory.java similarity index 98% rename from iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java rename to iped-app/src/main/java/iped/app/ui/ai/util/AIPayloadFactory.java index fd15b17d2e..38990999bc 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIPayloadFactory.java +++ b/iped-app/src/main/java/iped/app/ui/ai/util/AIPayloadFactory.java @@ -1,5 +1,6 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.util; +import iped.app.ui.ai.model.ContextFileEntry; import iped.app.ui.ai.backend.AIInitMultiChatRequest; import iped.app.ui.ai.backend.AIInitMultiChatRequest.SummarizedChat; import iped.data.IItem; diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java b/iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java similarity index 99% rename from iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java rename to iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java index 08212fb257..0399c70c4a 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIWhatsappChatExtractor.java +++ b/iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.util; import iped.data.IItem; import java.io.ByteArrayOutputStream; diff --git a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java similarity index 98% rename from iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java rename to iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 7846efc732..ec31194ba2 100644 --- a/iped-app/src/main/java/iped/app/ui/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -1,4 +1,4 @@ -package iped.app.ui; +package iped.app.ui.ai.view; import java.awt.*; import java.awt.event.KeyAdapter; @@ -16,14 +16,15 @@ import javax.swing.text.StyledDocument; import iped.app.ui.ai.AIChatCoordinator; -import iped.app.ui.ai.AIChatMessage; -import iped.app.ui.ai.AIContextManager; -import iped.app.ui.ai.ContextChangeEvent; -import iped.app.ui.ai.ContextChangeListener; -import iped.app.ui.ai.ContextFileEntry; -import iped.app.ui.ai.AIMarkdownRenderer; +import iped.app.ui.ai.model.AIChatMessage; +import iped.app.ui.ai.context.AIContextManager; +import iped.app.ui.ai.context.ContextChangeEvent; +import iped.app.ui.ai.context.ContextChangeListener; +import iped.app.ui.ai.model.ContextFileEntry; import iped.app.ui.ai.backend.AIBackendClient; import iped.app.ui.ai.backend.AIBackendConfig; +import iped.app.ui.App; +import iped.app.ui.Messages; import iped.data.IItem; /** diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIMarkdownRenderer.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java similarity index 99% rename from iped-app/src/main/java/iped/app/ui/ai/AIMarkdownRenderer.java rename to iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java index 5849ec7c68..b00430afcc 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIMarkdownRenderer.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java @@ -1,4 +1,4 @@ -package iped.app.ui.ai; +package iped.app.ui.ai.view; import java.util.HashMap; import java.util.List; @@ -14,6 +14,8 @@ import javax.swing.text.StyleConstants; import javax.swing.text.StyledDocument; +import iped.app.ui.ai.model.AIChatMessage; + /** * Renders assistant chat messages with lightweight Markdown support. */ From f2923a486be4bc81d8438f7935663e6b8b2f4403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Wed, 29 Apr 2026 17:25:10 -0300 Subject: [PATCH 056/143] feat: implement clickable token functionality in chat UI for enhanced navigation --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 107 ++++++++++++++++++ .../app/ui/ai/view/AIMarkdownRenderer.java | 45 +++++++- 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 1f5ba2bf22..ee94459bd8 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -1,6 +1,8 @@ package iped.app.ui.ai.view; import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.util.ArrayList; @@ -26,6 +28,11 @@ import iped.app.ui.App; import iped.app.ui.Messages; import iped.data.IItem; +import iped.engine.search.IPEDSearcher; +import iped.engine.search.MultiSearchResult; +import iped.data.IItemId; +import iped.app.ui.FileProcessor; +import iped.properties.ExtraProperties; /** * AI Assistant floating panel UI layer for IPED. @@ -140,6 +147,7 @@ private void createUI() { try { markdownRenderer = new AIMarkdownRenderer(chatArea); chatDocument = markdownRenderer.getDocument(); + installTokenClickHandler(); } catch (Throwable t) { System.err.println("Failed to initialize markdown renderer: " + t.getMessage()); t.printStackTrace(); @@ -164,6 +172,105 @@ private void createUI() { addMessage("System", "AI Assistant ready. Connected to local Backend server.\nRight-click an HTML WhatsApp chat export to add it to the context, then type your question."); } + private void installTokenClickHandler() { + chatArea.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() != MouseEvent.BUTTON1) { + return; + } + + int offset = chatArea.viewToModel2D(e.getPoint()); + if (offset < 0 || chatDocument == null) { + return; + } + + javax.swing.text.Element element = chatDocument.getCharacterElement(offset); + javax.swing.text.AttributeSet attributes = element.getAttributes(); + Object tokenFlag = attributes.getAttribute(AIMarkdownRenderer.TOKEN_ATTRIBUTE); + if (!Boolean.TRUE.equals(tokenFlag)) { + return; + } + + int start = element.getStartOffset(); + int end = element.getEndOffset(); + chatArea.setSelectionStart(start); + chatArea.setSelectionEnd(Math.max(start, end - 1)); + + Object hash = attributes.getAttribute(AIMarkdownRenderer.TOKEN_HASH_ATTRIBUTE); + Object chunkId = attributes.getAttribute(AIMarkdownRenderer.TOKEN_CHUNK_ID_ATTRIBUTE); + navigateToItem(String.valueOf(hash), String.valueOf(chunkId)); + } + }); + } + + private void navigateToItem(String hash, String chunkId) { + if (hash == null || hash.isEmpty() || App.get() == null || App.get().appCase == null) { + return; + } + + new Thread(() -> { + try { + IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); + MultiSearchResult result = searcher.multiSearch(); + if (result == null || result.getLength() == 0) { + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(dialog, + "Item not found for hash: " + hash, "Not found", JOptionPane.INFORMATION_MESSAGE)); + return; + } + + IItemId itemId = result.getItem(0); + int luceneId = App.get().appCase.getLuceneId(itemId); + + // Prepare viewer to scroll to the specific message/chunk position + // chunkId corresponds to parentViewPosition in chat metadata + if (chunkId != null && !chunkId.isEmpty()) { + try { + App.get().getViewerController().getHtmlLinkViewer().setElementIDToScroll(chunkId); + } catch (Exception e) { + // Viewer might not be HTML or element not available; continue anyway + } + } + + FileProcessor fp = new FileProcessor(luceneId, true); + fp.execute(); + + // After opening the file, select it in the results table + SwingUtilities.invokeLater(() -> selectItemInResultsTable(luceneId)); + + } catch (Exception ex) { + ex.printStackTrace(); + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(dialog, + "Error opening item: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE)); + } + }).start(); + } + + private void selectItemInResultsTable(int luceneId) { + if (App.get() == null || App.get().getResults() == null || App.get().getResultsTable() == null) { + return; + } + + // Search for the item with the given luceneId in the results + for (int i = 0; i < App.get().getResults().getLength(); i++) { + try { + IItemId item = App.get().getResults().getItem(i); + if (App.get().appCase.getLuceneId(item) == luceneId) { + // Found it! Select this row in the results table + int viewIndex = App.get().getResultsTable().convertRowIndexToView(i); + App.get().getResultsTable().setRowSelectionInterval(viewIndex, viewIndex); + + // Scroll to make it visible + java.awt.Rectangle cellRect = App.get().getResultsTable().getCellRect(viewIndex, 0, false); + App.get().getResultsTable().scrollRectToVisible(cellRect); + break; + } + } catch (Exception e) { + // Continue searching if there's an error with this item + } + } + } + private JPanel createHeaderPanel() { JPanel headerPanel = new JPanel(new BorderLayout()); diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java index b00430afcc..a96d8f7ac2 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java @@ -21,6 +21,10 @@ */ public class AIMarkdownRenderer { + public static final String TOKEN_ATTRIBUTE = "ai-token"; + public static final String TOKEN_HASH_ATTRIBUTE = "ai-token-hash"; + public static final String TOKEN_CHUNK_ID_ATTRIBUTE = "ai-token-chunk-id"; + private static final Logger LOGGER = LoggerFactory.getLogger(AIMarkdownRenderer.class); private final JTextPane chatArea; @@ -180,6 +184,13 @@ private void configureChatStyles() { Style blockquote = chatArea.addStyle("blockquote", base); StyleConstants.setForeground(blockquote, new java.awt.Color(0x55, 0x55, 0x55)); chatStyles.put("blockquote", blockquote); + + Style token = chatArea.addStyle("token", base); + StyleConstants.setForeground(token, new java.awt.Color(0x0b, 0x57, 0xd0)); + StyleConstants.setUnderline(token, true); + StyleConstants.setBold(token, true); + StyleConstants.setBackground(token, new java.awt.Color(0xe8, 0xf0, 0xfe)); + chatStyles.put("token", token); } /** @@ -283,6 +294,8 @@ private void appendMarkdown(String markdown, SimpleAttributeSet container) throw continue; } + + if (renderStandaloneItalicLine(lineToRender, baseStyle)) { debugLineType("standalone-italic", lineToRender, inUnderscoreItalicBlock); applyContainerAttributes(lineStart, container); @@ -448,10 +461,11 @@ private AttributeSet combineStyles(AttributeSet... styles) { private void appendInlineMarkdown(String text, AttributeSet baseStyle) throws BadLocationException { int index = 0; while (index < text.length()) { + int tokenStart = text.indexOf("<<", index); int boldStart = text.indexOf("**", index); int italicStart = findItalicStart(text, index); - int next = smallestPositive(boldStart, italicStart); + int next = smallestPositive(tokenStart, boldStart, italicStart); if (next == -1) { chatDocument.insertString(chatDocument.getLength(), text.substring(index), baseStyle); return; @@ -461,6 +475,23 @@ private void appendInlineMarkdown(String text, AttributeSet baseStyle) throws Ba chatDocument.insertString(chatDocument.getLength(), text.substring(index, next), baseStyle); } + if (next == tokenStart) { + int end = text.indexOf(">>", tokenStart + 2); + if (end > tokenStart + 2) { + String tokenText = text.substring(tokenStart + 2, end); + String[] tokenParts = tokenText.split("-", 2); + if (tokenParts.length == 2 && !tokenParts[0].isBlank() && !tokenParts[1].isBlank()) { + appendToken(text.substring(tokenStart, end + 2), tokenParts[0], tokenParts[1], baseStyle); + index = end + 2; + continue; + } + } + + chatDocument.insertString(chatDocument.getLength(), "<<", baseStyle); + index = tokenStart + 2; + continue; + } + if (next == boldStart) { int end = text.indexOf("**", boldStart + 2); if (end > boldStart + 2) { @@ -563,4 +594,16 @@ private int smallestPositive(int... values) { } return result; } + + /** + * Writes a chunk token using a dedicated clickable style and embedded metadata. + */ + private void appendToken(String visibleText, String hash, String chunkId, AttributeSet baseStyle) throws BadLocationException { + SimpleAttributeSet tokenStyle = new SimpleAttributeSet(chatArea.getStyle("token")); + tokenStyle.addAttributes(baseStyle); + tokenStyle.addAttribute(TOKEN_ATTRIBUTE, Boolean.TRUE); + tokenStyle.addAttribute(TOKEN_HASH_ATTRIBUTE, hash); + tokenStyle.addAttribute(TOKEN_CHUNK_ID_ATTRIBUTE, chunkId); + chatDocument.insertString(chatDocument.getLength(), visibleText, tokenStyle); + } } From cae115fa5c1fcb748fbd1d297bd0d85e3ba5006c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 30 Apr 2026 16:06:30 -0300 Subject: [PATCH 057/143] refactor: replace JDialog with JFrame for AI Assistant panel to improve UI management Co-authored-by: Copilot --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index ee94459bd8..27039907e4 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -55,7 +55,7 @@ public class AIAssistantPanel { private static final Pattern STREAM_PART_PATTERN = Pattern.compile("\\S+|\\s+"); // Main UI components - private JDialog dialog; + private JFrame frame; private JTextPane chatArea; private JScrollPane chatScrollPane; private StyledDocument chatDocument; @@ -126,8 +126,9 @@ private void createUI() { String title = "AI Assistant"; try { title = Messages.getString("AIAssistant.Title"); } catch (Exception e) {} - dialog = new JDialog(App.get(), title, false); - dialog.setResizable(true); + frame = new JFrame(title); + frame.setDefaultCloseOperation(WindowConstants.HIDE_ON_CLOSE); + frame.setResizable(true); JPanel mainPanel = new JPanel(new BorderLayout(5, 5)); mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); @@ -165,8 +166,8 @@ private void createUI() { mainPanel.add(centerPanel, BorderLayout.CENTER); mainPanel.add(createBottomPanel(), BorderLayout.SOUTH); - dialog.getContentPane().add(mainPanel); - dialog.pack(); + frame.getContentPane().add(mainPanel); + frame.pack(); positionDialog(); addMessage("System", "AI Assistant ready. Connected to local Backend server.\nRight-click an HTML WhatsApp chat export to add it to the context, then type your question."); @@ -214,7 +215,7 @@ private void navigateToItem(String hash, String chunkId) { IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); MultiSearchResult result = searcher.multiSearch(); if (result == null || result.getLength() == 0) { - SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(dialog, + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(frame, "Item not found for hash: " + hash, "Not found", JOptionPane.INFORMATION_MESSAGE)); return; } @@ -240,7 +241,7 @@ private void navigateToItem(String hash, String chunkId) { } catch (Exception ex) { ex.printStackTrace(); - SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(dialog, + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(frame, "Error opening item: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE)); } }).start(); @@ -555,16 +556,16 @@ private void positionDialog() { Rectangle screenBounds = resolvePreferredScreenBounds(); int height = (int) (screenBounds.height * HEIGHT_PERCENTAGE); - dialog.setSize(PANEL_WIDTH + 150, height); + frame.setSize(PANEL_WIDTH + 150, height); - int x = screenBounds.x + screenBounds.width - dialog.getWidth() - HORIZONTAL_OFFSET; + int x = screenBounds.x + screenBounds.width - frame.getWidth() - HORIZONTAL_OFFSET; int y = screenBounds.y + VERTICAL_OFFSET; - if (y + dialog.getHeight() > screenBounds.y + screenBounds.height) { - y = screenBounds.y + screenBounds.height - dialog.getHeight(); + if (y + frame.getHeight() > screenBounds.y + screenBounds.height) { + y = screenBounds.y + screenBounds.height - frame.getHeight(); } - dialog.setLocation(x, y); + frame.setLocation(x, y); } private Rectangle resolvePreferredScreenBounds() { @@ -590,7 +591,7 @@ private Rectangle getVirtualScreenBounds() { private void ensureVisibleOnScreen() { Rectangle virtualBounds = getVirtualScreenBounds(); - Rectangle currentBounds = dialog.getBounds(); + Rectangle currentBounds = frame.getBounds(); if (!virtualBounds.intersects(currentBounds)) { positionDialog(); } @@ -598,11 +599,11 @@ private void ensureVisibleOnScreen() { private void showDialogSafely() { ensureVisibleOnScreen(); - if (!dialog.isVisible()) { - dialog.setVisible(true); + if (!frame.isVisible()) { + frame.setVisible(true); } - dialog.toFront(); - dialog.requestFocus(); + frame.toFront(); + frame.requestFocus(); inputArea.requestFocusInWindow(); } @@ -752,7 +753,7 @@ private void setProcessing(boolean processing) { progressBar.setVisible(processing); sendButton.setEnabled(!processing); inputArea.setEnabled(!processing); - dialog.setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) : Cursor.getDefaultCursor()); + frame.setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) : Cursor.getDefaultCursor()); } private void appendFinalizedMessage(AIChatMessage message) { @@ -813,8 +814,8 @@ private void restoreScrollAfterIncrementalUpdate(boolean shouldAutoFollow, int p public void toggleVisibility() { Runnable action = () -> { - if (dialog.isVisible()) { - dialog.setVisible(false); + if (frame.isVisible()) { + frame.setVisible(false); } else { showDialogSafely(); } From 141c254be3c3d2fd4d0b9096e849d7fcf7ef39e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 30 Apr 2026 16:29:09 -0300 Subject: [PATCH 058/143] feat: add Clear Chat History button and functionality to reset chat UI Co-authored-by: Copilot --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 27039907e4..5adddfb1b3 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -55,8 +55,8 @@ public class AIAssistantPanel { private static final Pattern STREAM_PART_PATTERN = Pattern.compile("\\S+|\\s+"); // Main UI components - private JFrame frame; - private JTextPane chatArea; + private JFrame frame; // main window + private JTextPane chatArea; private JScrollPane chatScrollPane; private StyledDocument chatDocument; private JTextArea inputArea; @@ -402,6 +402,7 @@ private void refreshContextUI() { contextPanel.repaint(); } + // Quick actions panel private JPanel createTasksPanel() { JPanel panel = new JPanel(); panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); @@ -420,8 +421,37 @@ private JPanel createTasksPanel() { panel.add(btn); panel.add(Box.createVerticalStrut(5)); } + + // Add separator + panel.add(Box.createVerticalStrut(10)); + JSeparator separator = new JSeparator(); + separator.setMaximumSize(new Dimension(200, 1)); + panel.add(separator); + panel.add(Box.createVerticalStrut(5)); + + // Clear Chat History button + JButton clearChatButton = new JButton("Clear Chat History"); + clearChatButton.setAlignmentX(Component.CENTER_ALIGNMENT); + clearChatButton.setMaximumSize(new Dimension(200, 30)); + clearChatButton.addActionListener(e -> clearChatHistory()); + panel.add(clearChatButton); + return panel; } + + // Action Button to clear the chat history, resetting the conversation and UI to a clean state + private void clearChatHistory() { + finalizedMessages.clear(); + draftMessage = null; + + try { + chatDocument.remove(0, chatDocument.getLength()); + } catch (BadLocationException e) { + System.err.println("Error clearing chat document: " + e.getMessage()); + } + + refreshChatArea(); + } private JPanel createBottomPanel() { JPanel bottomPanel = new JPanel(new BorderLayout(5, 5)); From f0278d36098f296bcd26386a74add271d1676cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 30 Apr 2026 16:54:33 -0300 Subject: [PATCH 059/143] feat: enhance context management with clickable removal functionality and dynamic item visibility Co-authored-by: Copilot --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 97 +++++++++++++++---- 1 file changed, 78 insertions(+), 19 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 5adddfb1b3..ea413e6c75 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -52,6 +52,8 @@ public class AIAssistantPanel { private static final int PANEL_WIDTH = 550; private static final int STREAM_APPEND_DELAY_MS = 30; private static final int AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 24; + private static final int CONTEXT_VISIBLE_ITEMS = 5; + private static final int CONTEXT_REMOVE_HOTZONE_PX = 28; private static final Pattern STREAM_PART_PATTERN = Pattern.compile("\\S+|\\s+"); // Main UI components @@ -281,7 +283,7 @@ private JPanel createHeaderPanel() { titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14)); // Update status label to indicate live connection - statusLabel = new JLabel("● Connected to local backend server"); + statusLabel = new JLabel("● Connected to local backend server"); statusLabel.setForeground(new Color(0, 150, 0)); // Green for active JPanel leftPanel = new JPanel(new BorderLayout()); @@ -298,15 +300,13 @@ private JPanel createContextSection() { contextListModel = new DefaultListModel<>(); contextList = new JList<>(contextListModel); contextList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - contextList.setVisibleRowCount(5); + contextList.setVisibleRowCount(CONTEXT_VISIBLE_ITEMS); contextList.setBackground(new Color(255, 255, 240)); - // Custom Cell Renderer mapping our ContextFileEntry ViewModel to the screen contextList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { - JLabel label = (JLabel) new DefaultListCellRenderer() - .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); - if (value instanceof ContextSummaryRow) { + JLabel label = (JLabel) new DefaultListCellRenderer() + .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); ContextSummaryRow summary = (ContextSummaryRow) value; label.setText(summary.toString()); label.setForeground(Color.DARK_GRAY); @@ -316,21 +316,45 @@ private JPanel createContextSection() { } if (value instanceof ContextFileEntry) { - ContextFileEntry entry = (ContextFileEntry) value; - if (entry.isValidForContext()) { - label.setText(entry.getFileName()); - label.setToolTipText(entry.getFullPath()); - } else { - String reason = entry.getValidationReason() != null ? entry.getValidationReason() : "Rejected item."; - label.setText(entry.getFileName() + " - " + reason); - label.setToolTipText(reason + " Path: " + entry.getFullPath()); - label.setForeground(new Color(180, 0, 0)); - } + return createContextEntryCell(list, (ContextFileEntry) value, isSelected); } + JLabel label = (JLabel) new DefaultListCellRenderer() + .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + label.setText(String.valueOf(value)); return label; }); + contextList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() != MouseEvent.BUTTON1) { + return; + } + + int index = contextList.locationToIndex(e.getPoint()); + if (index < 0 || index >= contextListModel.size()) { + return; + } + + Rectangle cellBounds = contextList.getCellBounds(index, index); + if (cellBounds == null || !cellBounds.contains(e.getPoint())) { + return; + } + + Object value = contextListModel.getElementAt(index); + if (!(value instanceof ContextFileEntry)) { + return; + } + + if (!isRemoveHotzoneClick(e, cellBounds)) { + return; + } + + AIContextManager.getInstance().removeContextFile(((ContextFileEntry) value).getItem()); + } + }); + JScrollPane contextScroll = new JScrollPane(contextList); contextScroll.setPreferredSize(new Dimension(PANEL_WIDTH - 10, 80)); @@ -381,13 +405,13 @@ private void refreshContextUI() { contextList.setVisible(true); clearContextButton.setEnabled(true); - int visibleCount = Math.min(5, entries.size()); + int visibleCount = Math.min(CONTEXT_VISIBLE_ITEMS, entries.size()); for (int i = 0; i < visibleCount; i++) { contextListModel.addElement(entries.get(i)); } - if (entries.size() > 5) { - int hiddenCount = entries.size() - 5; + if (entries.size() > CONTEXT_VISIBLE_ITEMS) { + int hiddenCount = entries.size() - CONTEXT_VISIBLE_ITEMS; String summaryText = "+ " + hiddenCount + " more items (" + validFiles.size() + " valid, " + invalidCount + " rejected)"; contextListModel.addElement(new ContextSummaryRow(summaryText)); } @@ -402,6 +426,41 @@ private void refreshContextUI() { contextPanel.repaint(); } + private JComponent createContextEntryCell(JList list, ContextFileEntry entry, boolean isSelected) { + JPanel rowPanel = new JPanel(new BorderLayout(8, 0)); + rowPanel.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8)); + rowPanel.setOpaque(true); + rowPanel.setBackground(isSelected ? list.getSelectionBackground() : list.getBackground()); + + JLabel textLabel = new JLabel(); + textLabel.setOpaque(false); + textLabel.setFont(list.getFont()); + textLabel.setForeground(isSelected ? list.getSelectionForeground() : list.getForeground()); + + JLabel removeLabel = new JLabel("X"); + removeLabel.setOpaque(false); + removeLabel.setFont(list.getFont().deriveFont(Font.BOLD)); + removeLabel.setForeground(new Color(160, 0, 0)); + + if (entry.isValidForContext()) { + textLabel.setText(entry.getFileName()); + rowPanel.setToolTipText(entry.getFullPath()); + } else { + String reason = entry.getValidationReason() != null ? entry.getValidationReason() : "Rejected item."; + textLabel.setText(entry.getFileName() + " - " + reason); + rowPanel.setToolTipText(reason + " Path: " + entry.getFullPath()); + textLabel.setForeground(isSelected ? list.getSelectionForeground() : new Color(180, 0, 0)); + } + + rowPanel.add(textLabel, BorderLayout.CENTER); + rowPanel.add(removeLabel, BorderLayout.EAST); + return rowPanel; + } + + private boolean isRemoveHotzoneClick(MouseEvent e, Rectangle cellBounds) { + return e.getX() >= cellBounds.x + cellBounds.width - CONTEXT_REMOVE_HOTZONE_PX; + } + // Quick actions panel private JPanel createTasksPanel() { JPanel panel = new JPanel(); From ea97083dca8b148fa88fd26c88ca82199a403238 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 4 May 2026 13:29:34 -0300 Subject: [PATCH 060/143] chore: rename "add all marked to context" to "add all checked to context" --- .../localization/iped-desktop-messages.properties | 2 +- iped-app/src/main/java/iped/app/ui/MenuClass.java | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/iped-app/resources/localization/iped-desktop-messages.properties b/iped-app/resources/localization/iped-desktop-messages.properties index 84c4a53357..f0cb244d89 100644 --- a/iped-app/resources/localization/iped-desktop-messages.properties +++ b/iped-app/resources/localization/iped-desktop-messages.properties @@ -222,7 +222,7 @@ FaceSimilarity.LoadingFace=Loading Face... FaceSimilarity.ExternalFaceNotFound=Face not found in external file! MenuClass.AddToGraph=Add to link analysis MenuClass.AddToAIContext=Add to AI context -MenuClass.AddAllMarkedToAIContext=Add all marked to AI context +MenuClass.AddAllCheckedToAIContext=Add all checked to AI context MenuClass.ChangeGalleryColCount=Change Gallery Column Count MenuClass.ChangeLayout=Change Vertical/Horizontal Layout MenuClass.CheckHighlighted=Check Highlighted items diff --git a/iped-app/src/main/java/iped/app/ui/MenuClass.java b/iped-app/src/main/java/iped/app/ui/MenuClass.java index bded030efa..c5b0d72a33 100644 --- a/iped-app/src/main/java/iped/app/ui/MenuClass.java +++ b/iped-app/src/main/java/iped/app/ui/MenuClass.java @@ -53,7 +53,7 @@ public class MenuClass extends JPopupMenu { JMenuItem exportHighlighted, copyHighlighted, checkHighlighted, uncheckHighlighted, readHighlighted, unreadHighlighted, exportChecked, copyChecked, saveBookmarks, loadBookmarks, checkHighlightedAndSubItems, uncheckHighlightedAndSubItems, checkHighlightedAndParent, uncheckHighlightedAndParent, checkHighlightedAndReferences, uncheckHighlightedAndReferences, checkHighlightedAndReferencedBy, uncheckHighlightedAndReferencedBy, changeGalleryColCount, defaultLayout, changeLayout, previewScreenshot, manageBookmarks, clearSearchHistory, importKeywords, navigateToParent, exportTerms, manageFilters, manageColumns, exportCheckedToZip, exportCheckedTreeToZip, - exportTree, exportTreeChecked, similarDocs, openViewfile, createReport, resetColLayout, lastColLayout, saveColLayout, addToGraph, addToAIContext, addAllMarkedToAIContext, navigateToParentChat, pinFirstColumns, similarImagesCurrent, similarImagesExternal, + exportTree, exportTreeChecked, similarDocs, openViewfile, createReport, resetColLayout, lastColLayout, saveColLayout, addToGraph, addToAIContext, addAllCheckedToAIContext, navigateToParentChat, pinFirstColumns, similarImagesCurrent, similarImagesExternal, similarFacesCurrent, similarFacesExternal, toggleTimelineView, uiZoom, catIconSize, savePanelsLayout, loadPanelsLayout; MenuListener menuListener = new MenuListener(this); @@ -351,21 +351,21 @@ public void actionPerformed(ActionEvent e) { }); this.add(addToAIContext); - addAllMarkedToAIContext = new JMenuItem(Messages.getString("MenuClass.AddAllMarkedToAIContext")); //$NON-NLS-1$ + addAllCheckedToAIContext = new JMenuItem(Messages.getString("MenuClass.AddAllCheckedToAIContext")); //$NON-NLS-1$ try { - addAllMarkedToAIContext.setIcon(iped.utils.IconUtil.getToolbarIcon("ai-assistant", App.resPath)); + addAllCheckedToAIContext.setIcon(iped.utils.IconUtil.getToolbarIcon("ai-assistant", App.resPath)); } catch (Exception ignored) {} - addAllMarkedToAIContext.addActionListener(e -> { + addAllCheckedToAIContext.addActionListener(e -> { new Thread(() -> { - List itemsToAdd = getMarkedItems(); + List itemsToAdd = getCheckedItems(); if (!itemsToAdd.isEmpty()) { AIContextManager.getInstance().addContextFiles(itemsToAdd); SwingUtilities.invokeLater(() -> AIAssistantPanel.getInstance().showPanel()); } }).start(); }); - this.add(addAllMarkedToAIContext); + this.add(addAllCheckedToAIContext); this.addSeparator(); @@ -376,7 +376,7 @@ public void actionPerformed(ActionEvent e) { } // Helper Methods moved outside the constructor - private List getMarkedItems() { + private List getCheckedItems() { LinkedHashSet selectedItems = new LinkedHashSet<>(); JTable table = App.get().getResultsTable(); From c4322fc9e5a42388d5582afce9b4128a5b5f2f07 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 4 May 2026 14:00:21 -0300 Subject: [PATCH 061/143] feat (context): add all highlighted items to context --- .../iped-desktop-messages.properties | 2 +- .../src/main/java/iped/app/ui/MenuClass.java | 41 +++++++++++++++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/iped-app/resources/localization/iped-desktop-messages.properties b/iped-app/resources/localization/iped-desktop-messages.properties index f0cb244d89..42d0e00da8 100644 --- a/iped-app/resources/localization/iped-desktop-messages.properties +++ b/iped-app/resources/localization/iped-desktop-messages.properties @@ -221,7 +221,7 @@ FaceSimilarity.MinScore=Minimum similarity (1-100) FaceSimilarity.LoadingFace=Loading Face... FaceSimilarity.ExternalFaceNotFound=Face not found in external file! MenuClass.AddToGraph=Add to link analysis -MenuClass.AddToAIContext=Add to AI context +MenuClass.AddAllHighlightedToAIContext=Add all highlighted to AI context MenuClass.AddAllCheckedToAIContext=Add all checked to AI context MenuClass.ChangeGalleryColCount=Change Gallery Column Count MenuClass.ChangeLayout=Change Vertical/Horizontal Layout diff --git a/iped-app/src/main/java/iped/app/ui/MenuClass.java b/iped-app/src/main/java/iped/app/ui/MenuClass.java index c5b0d72a33..024571c01f 100644 --- a/iped-app/src/main/java/iped/app/ui/MenuClass.java +++ b/iped-app/src/main/java/iped/app/ui/MenuClass.java @@ -53,7 +53,7 @@ public class MenuClass extends JPopupMenu { JMenuItem exportHighlighted, copyHighlighted, checkHighlighted, uncheckHighlighted, readHighlighted, unreadHighlighted, exportChecked, copyChecked, saveBookmarks, loadBookmarks, checkHighlightedAndSubItems, uncheckHighlightedAndSubItems, checkHighlightedAndParent, uncheckHighlightedAndParent, checkHighlightedAndReferences, uncheckHighlightedAndReferences, checkHighlightedAndReferencedBy, uncheckHighlightedAndReferencedBy, changeGalleryColCount, defaultLayout, changeLayout, previewScreenshot, manageBookmarks, clearSearchHistory, importKeywords, navigateToParent, exportTerms, manageFilters, manageColumns, exportCheckedToZip, exportCheckedTreeToZip, - exportTree, exportTreeChecked, similarDocs, openViewfile, createReport, resetColLayout, lastColLayout, saveColLayout, addToGraph, addToAIContext, addAllCheckedToAIContext, navigateToParentChat, pinFirstColumns, similarImagesCurrent, similarImagesExternal, + exportTree, exportTreeChecked, similarDocs, openViewfile, createReport, resetColLayout, lastColLayout, saveColLayout, addToGraph, addAllHighlightedToAIContext, addAllCheckedToAIContext, navigateToParentChat, pinFirstColumns, similarImagesCurrent, similarImagesExternal, similarFacesCurrent, similarFacesExternal, toggleTimelineView, uiZoom, catIconSize, savePanelsLayout, loadPanelsLayout; MenuListener menuListener = new MenuListener(this); @@ -336,21 +336,30 @@ public void actionPerformed(ActionEvent e) { addToGraph.addActionListener(menuListener); this.add(addToGraph); - // AI Context - addToAIContext = new JMenuItem(Messages.getString("MenuClass.AddToAIContext")); //$NON-NLS-1$ + // AI Context: Add Highlighted + addAllHighlightedToAIContext = new JMenuItem(Messages.getString("MenuClass.AddAllHighlightedToAIContext")); try { - addToAIContext.setIcon(iped.utils.IconUtil.getToolbarIcon("ai-assistant", App.resPath)); + addAllHighlightedToAIContext.setIcon(iped.utils.IconUtil.getToolbarIcon("ai-assistant", App.resPath)); } catch (Exception ignored) {} - addToAIContext.setEnabled(item != null); - addToAIContext.addActionListener(e -> { + addAllHighlightedToAIContext.addActionListener(e -> { new Thread(() -> { - AIContextManager.getInstance().addContextFile(item); - SwingUtilities.invokeLater(() -> AIAssistantPanel.getInstance().showPanel()); + List itemsToAdd = getHighlightedItems(); + + // Fallback: If no table rows are highlighted, or user clicked from the tree, use the single right-clicked item + if (itemsToAdd.isEmpty() && item != null) { + itemsToAdd.add(item); + } + + if (!itemsToAdd.isEmpty()) { + AIContextManager.getInstance().addContextFiles(itemsToAdd); + SwingUtilities.invokeLater(() -> AIAssistantPanel.getInstance().showPanel()); + } }).start(); }); - this.add(addToAIContext); + this.add(addAllHighlightedToAIContext); + // AI Context: Add Checked addAllCheckedToAIContext = new JMenuItem(Messages.getString("MenuClass.AddAllCheckedToAIContext")); //$NON-NLS-1$ try { addAllCheckedToAIContext.setIcon(iped.utils.IconUtil.getToolbarIcon("ai-assistant", App.resPath)); @@ -398,6 +407,20 @@ private List getCheckedItems() { return new ArrayList<>(selectedItems); } + private List getHighlightedItems() { + LinkedHashSet selectedItems = new LinkedHashSet<>(); + JTable table = App.get().getResultsTable(); + + // If we are in the main results table, grab all highlighted rows + if (table != null && !isTreeMenu) { + int[] selectedRows = table.getSelectedRows(); + for (int viewRow : selectedRows) { + selectedItems.add(resolveItemFromRow(table, viewRow)); + } + } + return new ArrayList<>(selectedItems); + } + private IItem resolveItemFromRow(JTable table, int viewRow) { // Converts the visual row index to the underlying model index, // then fetches the item ID from IPED's search result model. From 9cf626ea0225b9e9ec896c2a7749b833cc9677ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Mon, 4 May 2026 14:46:09 -0300 Subject: [PATCH 062/143] feat: enhance chat link rendering with whataspp chat name insted of hash+chunkid for improved context visibility Co-authored-by: Copilot --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 2 +- .../app/ui/ai/view/AIMarkdownRenderer.java | 32 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index ea413e6c75..fe4031a024 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -198,7 +198,7 @@ public void mouseClicked(MouseEvent e) { int start = element.getStartOffset(); int end = element.getEndOffset(); chatArea.setSelectionStart(start); - chatArea.setSelectionEnd(Math.max(start, end - 1)); + chatArea.setSelectionEnd(Math.max(start, end)); Object hash = attributes.getAttribute(AIMarkdownRenderer.TOKEN_HASH_ATTRIBUTE); Object chunkId = attributes.getAttribute(AIMarkdownRenderer.TOKEN_CHUNK_ID_ATTRIBUTE); diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java index a96d8f7ac2..800fae79b7 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java @@ -14,6 +14,8 @@ import javax.swing.text.StyleConstants; import javax.swing.text.StyledDocument; +import iped.app.ui.ai.context.AIContextManager; +import iped.app.ui.ai.model.ContextFileEntry; import iped.app.ui.ai.model.AIChatMessage; /** @@ -481,7 +483,9 @@ private void appendInlineMarkdown(String text, AttributeSet baseStyle) throws Ba String tokenText = text.substring(tokenStart + 2, end); String[] tokenParts = tokenText.split("-", 2); if (tokenParts.length == 2 && !tokenParts[0].isBlank() && !tokenParts[1].isBlank()) { - appendToken(text.substring(tokenStart, end + 2), tokenParts[0], tokenParts[1], baseStyle); + String fallbackVisibleText = text.substring(tokenStart, end + 2); + String visibleText = resolveTokenVisibleText(tokenParts[0], fallbackVisibleText); + appendToken(visibleText, tokenParts[0], tokenParts[1], baseStyle); index = end + 2; continue; } @@ -595,6 +599,32 @@ private int smallestPositive(int... values) { return result; } + /** + * Resolves the visual text for a token without changing its click metadata. + */ + private String resolveTokenVisibleText(String hash, String fallbackVisibleText) { + if (hash == null || hash.isBlank()) { + return fallbackVisibleText; + } + + for (ContextFileEntry entry : AIContextManager.getInstance().getContextEntriesForUI()) { + if (entry == null || !entry.isValidForContext() || entry.getItem() == null) { + continue; + } + + String itemHash = entry.getItem().getHash(); + if (itemHash != null && itemHash.equalsIgnoreCase(hash)) { + String fileName = entry.getFileName(); + if (fileName != null && !fileName.isBlank()) { + return fileName; + } + break; + } + } + + return fallbackVisibleText; + } + /** * Writes a chunk token using a dedicated clickable style and embedded metadata. */ From 7e0b3b44f7375ee7b0e8b8de5a373f6f9d77882a Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 4 May 2026 15:15:46 -0300 Subject: [PATCH 063/143] chore (UI): add explicit [Status] and [Thinking] tags to llm response. --- .../iped/app/ui/ai/AIChatCoordinator.java | 13 +----- .../app/ui/ai/backend/AIBackendClient.java | 46 +++++++++++++++---- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index c24f276147..e60fde48f1 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -76,7 +76,7 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o try { // Step A: Initialize the Chat (Only if context changed) if (contextChanged) { - uiCallback.accept("[System]: Initializing context...\n"); + uiCallback.accept("**[System]:** Initializing context...\n\n"); chatHistory.clear(); currentChatHashes.clear(); @@ -99,7 +99,6 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o // Step B: Stream the response StringBuilder fullResponse = new StringBuilder(); - uiCallback.accept("[Assistant]: "); // Route to the correct streaming endpoint if (validEntries.size() == 1) { @@ -119,9 +118,8 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o uiCallback.accept("\n\n"); // Step C: Save the turn to history - String finalAnswer = cleanThinkingTags(fullResponse.toString()); chatHistory.add(new AIStreamChatRequest.AIMessage("user", question)); - chatHistory.add(new AIStreamChatRequest.AIMessage("assistant", finalAnswer)); + chatHistory.add(new AIStreamChatRequest.AIMessage("assistant", fullResponse.toString())); } catch (Exception e) { onError.accept("Backend error: " + e.getMessage()); @@ -133,11 +131,4 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o } }).start(); } - - /** - * Removes italicized thinking tags before saving to LLM history. - */ - private String cleanThinkingTags(String rawResponse) { - return rawResponse.replaceAll("(?m)^_.*_$\\n?", "").trim(); - } } \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index 53bc991657..f0a2ce9d00 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -154,10 +154,12 @@ public void streamChatResponse(String chatHash, String question, List chatHashes, String question, Li // The SSE parsing logic is identical to the single-chat stream try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { String line; + + // Add a flag to track the first final token + boolean isFirstFinalToken = true; + while ((line = reader.readLine()) != null) { if (line.startsWith("data: ")) { String jsonData = line.substring(6).trim(); + if (jsonData.isEmpty() || jsonData.equals("[DONE]")) continue; JsonObject eventObj = JsonParser.parseString(jsonData).getAsJsonObject(); @@ -286,10 +301,21 @@ public void streamMultiChatResponse(List chatHashes, String question, Li if (eventObj.has("content")) { String content = eventObj.get("content").getAsString(); - if (type.equals("status") || type.equals("thinking")) { - eventHandler.accept("\n_" + content + "_\n"); + if (type.equals("status")) { + // Format metadata in italics for the UI, with explicit 'status' indication + eventHandler.accept("\n_**[Status]:** " + content + "_\n"); + } else if (type.equals("thinking")) { + // Format metadata in italics for the UI, with explicit 'thinking' indication + eventHandler.accept("\n_**[Thinking]:** " + content + "_\n"); } else if (type.equals("final")) { - eventHandler.accept(content); + + // Check the flag before appending + if (isFirstFinalToken) { + eventHandler.accept("\n" + content); // Add newline for the first word + isFirstFinalToken = false; // Turn the flag off + } else { + eventHandler.accept(content); // Print normally for the rest + } } else if (type.equals("error")) { throw new AIBackendException("Backend Streaming Error: " + content); } From 2d511aae0a6b3a08bcee6f9db895ad30de7f6ec0 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 4 May 2026 16:07:08 -0300 Subject: [PATCH 064/143] fix (clear chat button): fixes the button so that it clears both the UI chat messages and the history. Button now acts as a general AI reset --- .../src/main/java/iped/app/ui/ai/AIChatCoordinator.java | 8 ++++++++ .../main/java/iped/app/ui/ai/view/AIAssistantPanel.java | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index c24f276147..383c0dbef1 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -140,4 +140,12 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o private String cleanThinkingTags(String rawResponse) { return rawResponse.replaceAll("(?m)^_.*_$\\n?", "").trim(); } + + public void clearHistory() { + this.chatHistory.clear(); + + // Also clear chat currentChatHashes and currentContextItemIds + currentChatHashes.clear(); + currentContextItemIds.clear(); + } } \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index fe4031a024..4c2b0284e6 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -503,6 +503,11 @@ private void clearChatHistory() { finalizedMessages.clear(); draftMessage = null; + // Wipe the Coordinator's memory + if (coordinator != null) { + coordinator.clearHistory(); + } + try { chatDocument.remove(0, chatDocument.getLength()); } catch (BadLocationException e) { From 2e0a292ef105f259ffb298862689e1b5ef16cd38 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 4 May 2026 16:22:36 -0300 Subject: [PATCH 065/143] fix: correct improper merge conflict resolution --- .../src/main/java/iped/app/ui/ai/AIChatCoordinator.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 02e76fa584..e3587bfa59 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -131,14 +131,7 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o } }).start(); } -<<<<<<< last-changes - /** - * Removes italicized thinking tags before saving to LLM history. - */ - private String cleanThinkingTags(String rawResponse) { - return rawResponse.replaceAll("(?m)^_.*_$\\n?", "").trim(); - } public void clearHistory() { this.chatHistory.clear(); @@ -147,6 +140,4 @@ public void clearHistory() { currentChatHashes.clear(); currentContextItemIds.clear(); } -======= ->>>>>>> pr-2646 } \ No newline at end of file From d17161f064ad25a81650a59083465775eeac9ff7 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Wed, 6 May 2026 14:13:15 -0300 Subject: [PATCH 066/143] feat: define conversation data model --- .../iped/app/ui/ai/model/Conversation.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java new file mode 100644 index 0000000000..7995d4c398 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java @@ -0,0 +1,58 @@ +package iped.app.ui.ai.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class Conversation { + private String id; + private String title; + private long createdAt; + private long lastModified; + private List contextIds; + private List chatHashes; + private List messages; + + public Conversation() { + this.id = UUID.randomUUID().toString(); // Universally Unique Identifier + this.createdAt = System.currentTimeMillis(); + this.lastModified = this.createdAt; + this.contextIds = new ArrayList<>(); + this.chatHashes = new ArrayList<>(); + this.messages = new ArrayList<>(); + this.title = "New Conversation"; + } + + // Standard Getters and Setters + public String getId() { return id; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public long getCreatedAt() { return createdAt; } + public long getLastModified() { return lastModified; } + public void updateLastModified() { this.lastModified = System.currentTimeMillis(); } + + public List getContextIds() { return contextIds; } + public void setContextIds(List contextIds) { this.contextIds = contextIds; } + + public List getChatHashes() { return chatHashes; } + public void setChatHashes(List chatHashes) { this.chatHashes = chatHashes; } + + public List getMessages() { return messages; } + public void setMessages(List messages) { this.messages = messages; } + + /** + * Auto-generates a title based on the first user message if the title is default + */ + public void autoGenerateTitle() { + if ("New Conversation".equals(this.title) && !messages.isEmpty()) { + for (AIChatMessage msg : messages) { + if ("user".equals(msg.getType())) { + String content = msg.getContent(); + this.title = content.length() > 30 ? content.substring(0, 27) + "..." : content; + break; + } + } + } + } +} From a5823b4b271a460d1de96afc82ee18d27f902e84 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Wed, 6 May 2026 14:44:33 -0300 Subject: [PATCH 067/143] feat (ui): add collapsible sidebar ui component --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 90 +++++++++++++++++-- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 4c2b0284e6..18b39e6bee 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -32,7 +32,6 @@ import iped.engine.search.MultiSearchResult; import iped.data.IItemId; import iped.app.ui.FileProcessor; -import iped.properties.ExtraProperties; /** * AI Assistant floating panel UI layer for IPED. @@ -49,7 +48,7 @@ public class AIAssistantPanel { private static final int HORIZONTAL_OFFSET = 30; private static final int VERTICAL_OFFSET = 120; private static final double HEIGHT_PERCENTAGE = 0.8; - private static final int PANEL_WIDTH = 550; + private static final int PANEL_WIDTH = 750; private static final int STREAM_APPEND_DELAY_MS = 30; private static final int AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 24; private static final int CONTEXT_VISIBLE_ITEMS = 5; @@ -65,6 +64,7 @@ public class AIAssistantPanel { private JButton sendButton; private JLabel statusLabel; private JProgressBar progressBar; + private AIMarkdownRenderer markdownRenderer; private final List finalizedMessages = new ArrayList<>(); private AIChatMessage draftMessage; @@ -73,6 +73,11 @@ public class AIAssistantPanel { private AIChatMessage streamingMessage; private Runnable streamDrainAction; + // Sidebar components + private JSplitPane splitPane; + private JPanel sidebarPanel; + private JButton newChatButton; + // Service layer that handles business logic and threading private AIChatCoordinator coordinator; @@ -135,8 +140,12 @@ private void createUI() { JPanel mainPanel = new JPanel(new BorderLayout(5, 5)); mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); + // Header mainPanel.add(createHeaderPanel(), BorderLayout.NORTH); + // Chat Workspace + JPanel chatWorkspacePanel = new JPanel(new BorderLayout(5, 5)); + JPanel centerPanel = new JPanel(new BorderLayout(5, 5)); centerPanel.add(createContextSection(), BorderLayout.NORTH); @@ -165,8 +174,20 @@ private void createUI() { JPanel tasksPanel = createTasksPanel(); centerPanel.add(tasksPanel, BorderLayout.EAST); - mainPanel.add(centerPanel, BorderLayout.CENTER); - mainPanel.add(createBottomPanel(), BorderLayout.SOUTH); + chatWorkspacePanel.add(centerPanel, BorderLayout.CENTER); + chatWorkspacePanel.add(createBottomPanel(), BorderLayout.SOUTH); + + // Conversations Sidebar + sidebarPanel = createSidebarPanel(); + + // The SplitPane connecting them + splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, sidebarPanel, chatWorkspacePanel); + splitPane.setContinuousLayout(true); + splitPane.setDividerSize(5); + splitPane.setBorder(null); // Keep it clean + splitPane.setDividerLocation(220); // Default sidebar width + + mainPanel.add(splitPane, BorderLayout.CENTER); frame.getContentPane().add(mainPanel); frame.pack(); @@ -277,17 +298,28 @@ private void selectItemInResultsTable(int luceneId) { private JPanel createHeaderPanel() { JPanel headerPanel = new JPanel(new BorderLayout()); + // Toggle Sidebar Button + JButton toggleSidebarBtn = new JButton("☰"); + toggleSidebarBtn.setMargin(new Insets(2, 6, 2, 6)); + toggleSidebarBtn.setFocusPainted(false); + toggleSidebarBtn.setToolTipText("Toggle Sidebar"); + toggleSidebarBtn.addActionListener(e -> toggleSidebar()); + String titleText = "AI Assistant"; try { titleText = Messages.getString("AIAssistant.Title"); } catch (Exception e) {} JLabel titleLabel = new JLabel(titleText); titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14)); - // Update status label to indicate live connection statusLabel = new JLabel("● Connected to local backend server"); statusLabel.setForeground(new Color(0, 150, 0)); // Green for active - JPanel leftPanel = new JPanel(new BorderLayout()); - leftPanel.add(titleLabel, BorderLayout.NORTH); + // Group toggle button and title + JPanel titleArea = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 0)); + titleArea.add(toggleSidebarBtn); + titleArea.add(titleLabel); + + JPanel leftPanel = new JPanel(new BorderLayout(0, 5)); + leftPanel.add(titleArea, BorderLayout.NORTH); leftPanel.add(statusLabel, BorderLayout.SOUTH); headerPanel.add(leftPanel, BorderLayout.WEST); @@ -296,6 +328,50 @@ private JPanel createHeaderPanel() { return headerPanel; } + private JPanel createSidebarPanel() { + JPanel panel = new JPanel(new BorderLayout(0, 5)); + panel.setMinimumSize(new Dimension(150, 0)); // Prevent crushing it too small + panel.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 5)); + + newChatButton = new JButton("+ New Chat"); + newChatButton.setFont(newChatButton.getFont().deriveFont(Font.BOLD)); + newChatButton.addActionListener(e -> startNewChat()); + + panel.add(newChatButton, BorderLayout.NORTH); + + // Placeholder for future step: JList of past conversations + JLabel placeholder = new JLabel("Chat history loading...", SwingConstants.CENTER); + placeholder.setForeground(Color.GRAY); + panel.add(placeholder, BorderLayout.CENTER); + + return panel; + } + + private void toggleSidebar() { + if (sidebarPanel.isVisible()) { + sidebarPanel.setVisible(false); + splitPane.setDividerLocation(0); + splitPane.setDividerSize(0); + } else { + sidebarPanel.setVisible(true); + splitPane.setDividerSize(5); + splitPane.setDividerLocation(220); + } + } + + private void startNewChat() { + // Clear UI and Coordinator memory + clearChatHistory(); + + // Clear IPED context + AIContextManager.getInstance().clearContext(); + + // In future step, ask ConversationManager to create a new active Conversation here + + addMessage("System", "Started a new conversation session."); + inputArea.requestFocusInWindow(); + } + private JPanel createContextSection() { contextListModel = new DefaultListModel<>(); contextList = new JList<>(contextListModel); From a59b363c08ca11bfdee70be8ba1215b0a6648c1e Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Wed, 6 May 2026 15:14:21 -0300 Subject: [PATCH 068/143] fix (quick actions): Change quick actions buttons to send a portuguese message rather than an english one, given that the llm always answers in portuguese --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 18b39e6bee..73c4e775d3 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -7,6 +7,7 @@ import java.awt.event.KeyEvent; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -19,19 +20,20 @@ import iped.app.ui.ai.AIChatCoordinator; import iped.app.ui.ai.model.AIChatMessage; +import iped.app.ui.ai.model.ContextFileEntry; import iped.app.ui.ai.context.AIContextManager; import iped.app.ui.ai.context.ContextChangeEvent; import iped.app.ui.ai.context.ContextChangeListener; -import iped.app.ui.ai.model.ContextFileEntry; import iped.app.ui.ai.backend.AIBackendClient; import iped.app.ui.ai.backend.AIBackendConfig; + import iped.app.ui.App; import iped.app.ui.Messages; +import iped.app.ui.FileProcessor; import iped.data.IItem; +import iped.data.IItemId; import iped.engine.search.IPEDSearcher; import iped.engine.search.MultiSearchResult; -import iped.data.IItemId; -import iped.app.ui.FileProcessor; /** * AI Assistant floating panel UI layer for IPED. @@ -543,6 +545,12 @@ private JPanel createTasksPanel() { panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); panel.setBorder(BorderFactory.createTitledBorder("Quick Actions")); + // Map English button text to Portuguese prompts + Map taskPrompts = new java.util.HashMap<>(); + taskPrompts.put("Summarize", "Resuma o arquivo fornecido."); + taskPrompts.put("Find Patterns", "Encontre padrões no arquivo fornecido."); + taskPrompts.put("Analyze Metadata", "Analise os metadados do arquivo fornecido."); + String[] tasks = {"Summarize", "Find Patterns", "Analyze Metadata"}; for (String task : tasks) { JButton btn = new JButton(task); @@ -550,7 +558,7 @@ private JPanel createTasksPanel() { btn.setMaximumSize(new Dimension(200, 30)); // Firing a pre-written prompt directly into the input area logic btn.addActionListener(e -> { - inputArea.setText(task + " the provided file."); + inputArea.setText(taskPrompts.get(task)); handleSendAction(); }); panel.add(btn); From edfcf9104e7691c4c908421db0f4966128062c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Wed, 6 May 2026 17:21:02 -0300 Subject: [PATCH 069/143] feat: implement collapsible thinking blocks in chat UI Co-authored-by: Copilot --- .../app/ui/ai/backend/AIBackendClient.java | 11 +- .../iped/app/ui/ai/view/AIAssistantPanel.java | 21 +-- .../app/ui/ai/view/AIMarkdownRenderer.java | 140 ++++++++++++++++++ 3 files changed, 159 insertions(+), 13 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index f0a2ce9d00..ff805fd88c 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -34,6 +34,9 @@ * {@link AIBackendConfig} is immutable.

*/ public class AIBackendClient implements AIBackendService { + + private static final String THINKING_BLOCK_PREFIX = "[[AI_THINKING]]"; + private static final String THINKING_BLOCK_SUFFIX = "[[/AI_THINKING]]"; private final AIBackendConfig config; private final HttpClient httpClient; @@ -179,8 +182,8 @@ public void streamChatResponse(String chatHash, String question, List chatHashes, String question, Li // Format metadata in italics for the UI, with explicit 'status' indication eventHandler.accept("\n_**[Status]:** " + content + "_\n"); } else if (type.equals("thinking")) { - // Format metadata in italics for the UI, with explicit 'thinking' indication - eventHandler.accept("\n_**[Thinking]:** " + content + "_\n"); + // Emit a dedicated block marker to avoid markdown parsing conflicts + eventHandler.accept("\n" + THINKING_BLOCK_PREFIX + content + THINKING_BLOCK_SUFFIX + "\n"); } else if (type.equals("final")) { // Check the flag before appending diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 4c2b0284e6..2715dedb5d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -191,18 +191,21 @@ public void mouseClicked(MouseEvent e) { javax.swing.text.Element element = chatDocument.getCharacterElement(offset); javax.swing.text.AttributeSet attributes = element.getAttributes(); Object tokenFlag = attributes.getAttribute(AIMarkdownRenderer.TOKEN_ATTRIBUTE); - if (!Boolean.TRUE.equals(tokenFlag)) { + if (Boolean.TRUE.equals(tokenFlag)) { + int start = element.getStartOffset(); + int end = element.getEndOffset(); + chatArea.setSelectionStart(start); + chatArea.setSelectionEnd(Math.max(start, end)); + + Object hash = attributes.getAttribute(AIMarkdownRenderer.TOKEN_HASH_ATTRIBUTE); + Object chunkId = attributes.getAttribute(AIMarkdownRenderer.TOKEN_CHUNK_ID_ATTRIBUTE); + navigateToItem(String.valueOf(hash), String.valueOf(chunkId)); return; } - int start = element.getStartOffset(); - int end = element.getEndOffset(); - chatArea.setSelectionStart(start); - chatArea.setSelectionEnd(Math.max(start, end)); - - Object hash = attributes.getAttribute(AIMarkdownRenderer.TOKEN_HASH_ATTRIBUTE); - Object chunkId = attributes.getAttribute(AIMarkdownRenderer.TOKEN_CHUNK_ID_ATTRIBUTE); - navigateToItem(String.valueOf(hash), String.valueOf(chunkId)); + if (markdownRenderer != null && markdownRenderer.toggleThinkingAtOffset(offset)) { + refreshChatArea(); + } } }); } diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java index 800fae79b7..10fd3d4409 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java @@ -26,13 +26,21 @@ public class AIMarkdownRenderer { public static final String TOKEN_ATTRIBUTE = "ai-token"; public static final String TOKEN_HASH_ATTRIBUTE = "ai-token-hash"; public static final String TOKEN_CHUNK_ID_ATTRIBUTE = "ai-token-chunk-id"; + public static final String THINKING_TOGGLE_ATTRIBUTE = "ai-thinking-toggle"; + public static final String THINKING_ID_ATTRIBUTE = "ai-thinking-id"; + + private static final String THINKING_BLOCK_PREFIX = "[[AI_THINKING]]"; + private static final String THINKING_BLOCK_SUFFIX = "[[/AI_THINKING]]"; private static final Logger LOGGER = LoggerFactory.getLogger(AIMarkdownRenderer.class); private final JTextPane chatArea; private final StyledDocument chatDocument; private final Map chatStyles = new HashMap<>(); + private final Map thinkingExpandedState = new HashMap<>(); private int draftStartOffset = -1; + private int nextThinkingBlockId = 0; + private int draftStartThinkingBlockId = -1; public AIMarkdownRenderer(JTextPane chatArea) { this.chatArea = chatArea; @@ -54,6 +62,8 @@ public StyledDocument getDocument() { public void renderMessages(List messages) { try { draftStartOffset = -1; + draftStartThinkingBlockId = -1; + nextThinkingBlockId = 0; chatDocument.remove(0, chatDocument.getLength()); for (AIChatMessage message : messages) { appendMessageToDocument(message); @@ -83,8 +93,12 @@ public void renderDraft(AIChatMessage message) { try { if (draftStartOffset < 0) { draftStartOffset = chatDocument.getLength(); + draftStartThinkingBlockId = nextThinkingBlockId; } else { chatDocument.remove(draftStartOffset, chatDocument.getLength() - draftStartOffset); + if (draftStartThinkingBlockId >= 0) { + nextThinkingBlockId = draftStartThinkingBlockId; + } } appendMessageToDocument(message); @@ -99,6 +113,7 @@ public void renderDraft(AIChatMessage message) { */ public void commitDraft() { draftStartOffset = -1; + draftStartThinkingBlockId = -1; } /** @@ -116,9 +131,38 @@ public void discardDraft() { e.printStackTrace(); } finally { draftStartOffset = -1; + if (draftStartThinkingBlockId >= 0) { + nextThinkingBlockId = draftStartThinkingBlockId; + } + draftStartThinkingBlockId = -1; } } + /** + * Toggles a thinking block when clicking on its header and returns true when handled. + */ + public boolean toggleThinkingAtOffset(int offset) { + if (offset < 0) { + return false; + } + + javax.swing.text.Element element = chatDocument.getCharacterElement(offset); + AttributeSet attributes = element.getAttributes(); + if (!Boolean.TRUE.equals(attributes.getAttribute(THINKING_TOGGLE_ATTRIBUTE))) { + return false; + } + + Object blockId = attributes.getAttribute(THINKING_ID_ATTRIBUTE); + if (!(blockId instanceof Integer)) { + return false; + } + + int id = ((Integer) blockId).intValue(); + boolean currentlyExpanded = thinkingExpandedState.getOrDefault(id, Boolean.TRUE); + thinkingExpandedState.put(id, !currentlyExpanded); + return true; + } + /** * Maps a message type to the corresponding style key. */ @@ -193,6 +237,17 @@ private void configureChatStyles() { StyleConstants.setBold(token, true); StyleConstants.setBackground(token, new java.awt.Color(0xe8, 0xf0, 0xfe)); chatStyles.put("token", token); + + Style thinkingHeader = chatArea.addStyle("thinking-header", base); + StyleConstants.setBold(thinkingHeader, true); + StyleConstants.setForeground(thinkingHeader, new java.awt.Color(0x1a, 0x56, 0xb3)); + StyleConstants.setBackground(thinkingHeader, new java.awt.Color(0xe8, 0xf0, 0xfe)); + chatStyles.put("thinking-header", thinkingHeader); + + Style thinkingBody = chatArea.addStyle("thinking-body", base); + StyleConstants.setForeground(thinkingBody, new java.awt.Color(0x38, 0x40, 0x45)); + StyleConstants.setBackground(thinkingBody, new java.awt.Color(0xf3, 0xf7, 0xfd)); + chatStyles.put("thinking-body", thinkingBody); } /** @@ -262,10 +317,67 @@ private void appendMarkdown(String markdown, SimpleAttributeSet container) throw AttributeSet italicBaseStyle = createBaseStyle(true); String[] lines = normalizedMarkdown.split("\n", -1); boolean inUnderscoreItalicBlock = false; + boolean inThinkingBlock = false; + StringBuilder thinkingContentBuffer = new StringBuilder(); for (String line : lines) { int lineStart = chatDocument.getLength(); String lineToRender = line; + + if (inThinkingBlock) { + int suffixIndex = lineToRender.indexOf(THINKING_BLOCK_SUFFIX); + if (suffixIndex < 0) { + if (thinkingContentBuffer.length() > 0) { + thinkingContentBuffer.append("\n"); + } + thinkingContentBuffer.append(lineToRender); + continue; + } + + if (suffixIndex > 0) { + if (thinkingContentBuffer.length() > 0) { + thinkingContentBuffer.append("\n"); + } + thinkingContentBuffer.append(lineToRender.substring(0, suffixIndex)); + } + + appendThinkingBlock(lineStart, thinkingContentBuffer.toString().trim(), container); + thinkingContentBuffer.setLength(0); + inThinkingBlock = false; + lineToRender = lineToRender.substring(suffixIndex + THINKING_BLOCK_SUFFIX.length()); + + if (lineToRender.trim().isEmpty()) { + continue; + } + } + + int prefixIndex = lineToRender.indexOf(THINKING_BLOCK_PREFIX); + if (prefixIndex >= 0) { + String beforePrefix = lineToRender.substring(0, prefixIndex); + if (!beforePrefix.trim().isEmpty()) { + appendInlineMarkdown(beforePrefix, normalBaseStyle); + chatDocument.insertString(chatDocument.getLength(), "\n", normalBaseStyle); + applyContainerAttributes(lineStart, container); + } + + String afterPrefix = lineToRender.substring(prefixIndex + THINKING_BLOCK_PREFIX.length()); + int suffixIndex = afterPrefix.indexOf(THINKING_BLOCK_SUFFIX); + if (suffixIndex >= 0) { + String thinkingContent = afterPrefix.substring(0, suffixIndex).trim(); + appendThinkingBlock(chatDocument.getLength(), thinkingContent, container); + String trailing = afterPrefix.substring(suffixIndex + THINKING_BLOCK_SUFFIX.length()); + if (trailing.trim().isEmpty()) { + continue; + } + lineToRender = trailing; + } else { + thinkingContentBuffer.setLength(0); + thinkingContentBuffer.append(afterPrefix); + inThinkingBlock = true; + continue; + } + } + String trimmed = lineToRender.trim(); int firstNonWhitespace = firstNonWhitespaceIndex(lineToRender); @@ -359,6 +471,34 @@ private void appendMarkdown(String markdown, SimpleAttributeSet container) throw inUnderscoreItalicBlock = false; } } + + if (inThinkingBlock && thinkingContentBuffer.length() > 0) { + appendThinkingBlock(chatDocument.getLength(), thinkingContentBuffer.toString().trim(), container); + } + } + + /** + * Renders a collapsible thinking block with a clickable header. + */ + private void appendThinkingBlock(int lineStart, String content, SimpleAttributeSet container) throws BadLocationException { + int blockId = nextThinkingBlockId++; + boolean expanded = thinkingExpandedState.getOrDefault(blockId, Boolean.TRUE); + + String toggleMarker = expanded ? "▼" : "▶"; + String headerText = toggleMarker + " Thinking\n"; + + SimpleAttributeSet headerStyle = new SimpleAttributeSet(chatArea.getStyle("thinking-header")); + headerStyle.addAttribute(THINKING_TOGGLE_ATTRIBUTE, Boolean.TRUE); + headerStyle.addAttribute(THINKING_ID_ATTRIBUTE, Integer.valueOf(blockId)); + chatDocument.insertString(chatDocument.getLength(), headerText, headerStyle); + + if (expanded && content != null && !content.isEmpty()) { + AttributeSet bodyStyle = combineStyles(createBaseStyle(false), chatArea.getStyle("thinking-body")); + appendInlineMarkdown(content, bodyStyle); + chatDocument.insertString(chatDocument.getLength(), "\n", bodyStyle); + } + + applyContainerAttributes(lineStart, container); } /** From 9b6d3825a0501acdf344ca7641cdceb289c4f51c Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 7 May 2026 12:12:25 -0300 Subject: [PATCH 070/143] feat: add conversation list UI, not populated yet --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 73c4e775d3..ab0ba7f8b8 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -21,6 +21,7 @@ import iped.app.ui.ai.AIChatCoordinator; import iped.app.ui.ai.model.AIChatMessage; import iped.app.ui.ai.model.ContextFileEntry; +import iped.app.ui.ai.model.Conversation; import iped.app.ui.ai.context.AIContextManager; import iped.app.ui.ai.context.ContextChangeEvent; import iped.app.ui.ai.context.ContextChangeListener; @@ -79,6 +80,8 @@ public class AIAssistantPanel { private JSplitPane splitPane; private JPanel sidebarPanel; private JButton newChatButton; + private JList conversationList; + private DefaultListModel conversationListModel; // Service layer that handles business logic and threading private AIChatCoordinator coordinator; @@ -332,7 +335,7 @@ private JPanel createHeaderPanel() { private JPanel createSidebarPanel() { JPanel panel = new JPanel(new BorderLayout(0, 5)); - panel.setMinimumSize(new Dimension(150, 0)); // Prevent crushing it too small + panel.setMinimumSize(new Dimension(150, 0)); panel.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 5)); newChatButton = new JButton("+ New Chat"); @@ -341,10 +344,31 @@ private JPanel createSidebarPanel() { panel.add(newChatButton, BorderLayout.NORTH); - // Placeholder for future step: JList of past conversations - JLabel placeholder = new JLabel("Chat history loading...", SwingConstants.CENTER); - placeholder.setForeground(Color.GRAY); - panel.add(placeholder, BorderLayout.CENTER); + // Conversation List UI + conversationListModel = new DefaultListModel<>(); + conversationList = new JList<>(conversationListModel); + conversationList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + + // Custom Renderer to make the items look like modern chat tabs + conversationList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { + JLabel label = (JLabel) new DefaultListCellRenderer() + .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + + if (value instanceof Conversation) { + Conversation conv = (Conversation) value; + label.setText(conv.getTitle()); + + // Add padding to make the row taller and easier to click + label.setBorder(BorderFactory.createEmptyBorder(8, 10, 8, 10)); + } + return label; + }); + + // Hide the scrollpane borders to blend seamlessly into the sidebar + JScrollPane scrollPane = new JScrollPane(conversationList); + scrollPane.setBorder(BorderFactory.createEmptyBorder()); + + panel.add(scrollPane, BorderLayout.CENTER); return panel; } From f2b1f8ea10465646539b94be80ae412acb411b1d Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 7 May 2026 12:51:31 -0300 Subject: [PATCH 071/143] feat: Add Conversation State Manager and populate the conversation list --- .../ui/ai/context/ConversationManager.java | 75 +++++++++++++++++++ .../iped/app/ui/ai/view/AIAssistantPanel.java | 30 ++++++-- 2 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java new file mode 100644 index 0000000000..482ebaafcd --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java @@ -0,0 +1,75 @@ +package iped.app.ui.ai.context; + +import iped.app.ui.ai.model.AIChatMessage; +import iped.app.ui.ai.model.Conversation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Singleton manager responsible for maintaining the state of AI conversations + * for the currently active IPED case. + */ +public class ConversationManager { + + private static ConversationManager instance; + + private final List conversations; + private Conversation activeConversation; + + private ConversationManager() { + this.conversations = new ArrayList<>(); + startNewConversation(); + } + + public static synchronized ConversationManager getInstance() { + if (instance == null) { + instance = new ConversationManager(); + } + return instance; + } + + public Conversation getActiveConversation() { + return activeConversation; + } + + /** + * Sets a specific conversation as active (used when clicking a chat in the sidebar). + */ + public void setActiveConversation(Conversation conversation) { + this.activeConversation = conversation; + if (!conversations.contains(conversation)) { + // Add new conversations to the top of the list + conversations.add(0, conversation); + } + } + + /** + * Initializes a fresh, empty conversation and sets it as active. + */ + public Conversation startNewConversation() { + Conversation newConv = new Conversation(); + setActiveConversation(newConv); + return newConv; + } + + public List getConversations() { + return Collections.unmodifiableList(conversations); + } + + /** + * Appends a message to the active conversation and auto-generates a title if needed. + */ + public void addMessageToActive(AIChatMessage message) { + if (activeConversation != null) { + activeConversation.getMessages().add(message); + activeConversation.updateLastModified(); + + // If this is the first user message, generate the title! + if ("New Conversation".equals(activeConversation.getTitle()) && "user".equals(message.getType())) { + activeConversation.autoGenerateTitle(); + } + } + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index ab0ba7f8b8..eb5a2964c9 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -25,6 +25,7 @@ import iped.app.ui.ai.context.AIContextManager; import iped.app.ui.ai.context.ContextChangeEvent; import iped.app.ui.ai.context.ContextChangeListener; +import iped.app.ui.ai.context.ConversationManager; import iped.app.ui.ai.backend.AIBackendClient; import iped.app.ui.ai.backend.AIBackendConfig; @@ -385,6 +386,14 @@ private void toggleSidebar() { } } + private void refreshSidebarList() { + conversationListModel.clear(); + for (Conversation conv : ConversationManager.getInstance().getConversations()) { + conversationListModel.addElement(conv); + } + conversationList.repaint(); + } + private void startNewChat() { // Clear UI and Coordinator memory clearChatHistory(); @@ -392,7 +401,9 @@ private void startNewChat() { // Clear IPED context AIContextManager.getInstance().clearContext(); - // In future step, ask ConversationManager to create a new active Conversation here + // Create a new active conversation in memory + ConversationManager.getInstance().startNewConversation(); + refreshSidebarList(); addMessage("System", "Started a new conversation session."); inputArea.requestFocusInWindow(); @@ -900,6 +911,11 @@ private void handleSendAction() { // Print user message immediately addMessage("You", text, "user"); inputArea.setText(""); + + // Push message to the manager + ConversationManager.getInstance().addMessageToActive( + AIChatMessage.now("You", text, "user") + ); // Lock the UI setProcessing(true); @@ -920,16 +936,16 @@ private void handleSendAction() { () -> javax.swing.SwingUtilities.invokeLater(() -> { completeStreaming(() -> { if (assistantDraft.getContent().isEmpty()) { - if (markdownRenderer != null) { - markdownRenderer.discardDraft(); - } + if (markdownRenderer != null) markdownRenderer.discardDraft(); draftMessage = null; } else { finalizedMessages.add(assistantDraft); - if (markdownRenderer != null) { - markdownRenderer.commitDraft(); - } + if (markdownRenderer != null) markdownRenderer.commitDraft(); draftMessage = null; + + // Save the LLM's answer to active conversation state + ConversationManager.getInstance().addMessageToActive(assistantDraft); + refreshSidebarList(); } setProcessing(false); }); From 0df9ba203c0d00ed190d07cc936d37b933d9c79a Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 7 May 2026 13:21:55 -0300 Subject: [PATCH 072/143] refactor: remove 'finalizedMessages' to avoid storing previous messages twice: in the UI and in the conversation manager --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index eb5a2964c9..1b81ea0733 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -70,7 +70,6 @@ public class AIAssistantPanel { private JProgressBar progressBar; private AIMarkdownRenderer markdownRenderer; - private final List finalizedMessages = new ArrayList<>(); private AIChatMessage draftMessage; private final List streamQueue = new ArrayList<>(); private Timer streamTimer; @@ -619,10 +618,11 @@ private JPanel createTasksPanel() { // Action Button to clear the chat history, resetting the conversation and UI to a clean state private void clearChatHistory() { - finalizedMessages.clear(); draftMessage = null; - // Wipe the Coordinator's memory + // Tell the manager to wipe the messages for this conversation + ConversationManager.getInstance().getActiveConversation().getMessages().clear(); + if (coordinator != null) { coordinator.clearHistory(); } @@ -744,7 +744,9 @@ private boolean ensureChatServiceInitialized() { private void addMessage(String sender, String message, String type) { AIChatMessage chatMessage = AIChatMessage.now(sender, message, type); - finalizedMessages.add(chatMessage); + + // Let the Manager hold the data + ConversationManager.getInstance().addMessageToActive(chatMessage); if (markdownRenderer != null) { appendFinalizedMessage(chatMessage); @@ -753,18 +755,21 @@ private void addMessage(String sender, String message, String type) { } } + private void addMessage(String sender, String message) { + addMessage(sender, message, "system"); + } + private List buildRenderableMessages() { - List renderableMessages = new ArrayList<>(finalizedMessages); + // Renderable messages are the messages in the current active conversation + List renderableMessages = new ArrayList<>( + ConversationManager.getInstance().getActiveConversation().getMessages() + ); if (draftMessage != null) { renderableMessages.add(draftMessage); } return renderableMessages; } - private void addMessage(String sender, String message) { - addMessage(sender, message, "system"); - } - private void positionDialog() { Rectangle screenBounds = resolvePreferredScreenBounds(); @@ -939,7 +944,6 @@ private void handleSendAction() { if (markdownRenderer != null) markdownRenderer.discardDraft(); draftMessage = null; } else { - finalizedMessages.add(assistantDraft); if (markdownRenderer != null) markdownRenderer.commitDraft(); draftMessage = null; From d7a8838a3d7b803eec6bb004da1308d350041dc0 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 7 May 2026 14:12:18 -0300 Subject: [PATCH 073/143] fix: protects historical chat data and added explicit clear function - Rename clearChatHistory to clearChatScreenAndMemory to prevent accidental data deletion - Fix startNewChat to protect historical conversation data - Implement clearCurrentChat to explicitly wipe active conversation state, hashes, and context --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 55 +++++++++++++++---- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 1b81ea0733..02a4d264be 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -394,15 +394,17 @@ private void refreshSidebarList() { } private void startNewChat() { + // Create a new active conversation in memory first (safeguards the old one) + ConversationManager.getInstance().startNewConversation(); + // Clear UI and Coordinator memory - clearChatHistory(); + clearChatScreenAndMemory(); // Clear IPED context AIContextManager.getInstance().clearContext(); - // Create a new active conversation in memory - ConversationManager.getInstance().startNewConversation(); refreshSidebarList(); + refreshChatArea(); addMessage("System", "Started a new conversation session."); inputArea.requestFocusInWindow(); @@ -610,30 +612,61 @@ private JPanel createTasksPanel() { JButton clearChatButton = new JButton("Clear Chat History"); clearChatButton.setAlignmentX(Component.CENTER_ALIGNMENT); clearChatButton.setMaximumSize(new Dimension(200, 30)); - clearChatButton.addActionListener(e -> clearChatHistory()); + clearChatButton.addActionListener(e -> clearCurrentChat()); panel.add(clearChatButton); return panel; } - // Action Button to clear the chat history, resetting the conversation and UI to a clean state - private void clearChatHistory() { - draftMessage = null; + /** + * Triggered by the Quick Actions panel. Erases the current conversation's history. + * Permanently erases the messages in this specific chat + */ + private void clearCurrentChat() { + Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); + + // Reset the title so the next user message triggers the title auto-generator + activeConv.setTitle("New Conversation"); + activeConv.updateLastModified(); + + // Delete the data from the Active Conversation model + activeConv.getMessages().clear(); - // Tell the manager to wipe the messages for this conversation - ConversationManager.getInstance().getActiveConversation().getMessages().clear(); + // Wipe the backend session markers so it truly starts fresh + activeConv.getChatHashes().clear(); + activeConv.getContextIds().clear(); + + // Wipe the screen and coordinator memory + clearChatScreenAndMemory(); + refreshSidebarList(); + refreshChatArea(); + + addMessage("System", "Current chat history cleared."); + inputArea.requestFocusInWindow(); + } + + /** + * Clears the UI and the Coordinator's memory. + * Does NOT delete the saved messages in the ConversationManager. + */ + private void clearChatScreenAndMemory() { + draftMessage = null; + + // Wipe the Coordinator's memory so the LLM forgets the previous context if (coordinator != null) { coordinator.clearHistory(); } + // Wipe the UI screen try { + if (markdownRenderer != null) { + markdownRenderer.commitDraft(); // Resets anchor to -1 + } chatDocument.remove(0, chatDocument.getLength()); } catch (BadLocationException e) { System.err.println("Error clearing chat document: " + e.getMessage()); } - - refreshChatArea(); } private JPanel createBottomPanel() { From b2c59786c71c7978049ab15b00eedf844bd0a7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Fri, 8 May 2026 14:48:13 -0300 Subject: [PATCH 074/143] feat: create thinking section rendering in chat UI --- .../app/ui/ai/backend/AIBackendClient.java | 42 +++++++++++++++---- .../app/ui/ai/view/AIMarkdownRenderer.java | 8 ++-- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index ff805fd88c..950bf20913 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -160,6 +160,7 @@ public void streamChatResponse(String chatHash, String question, List chatHashes, String question, Li // Add a flag to track the first final token boolean isFirstFinalToken = true; + boolean isThinkingBlockOpen = false; while ((line = reader.readLine()) != null) { if (line.startsWith("data: ")) { @@ -301,6 +316,15 @@ public void streamMultiChatResponse(List chatHashes, String question, Li JsonObject eventObj = JsonParser.parseString(jsonData).getAsJsonObject(); String type = eventObj.has("type") ? eventObj.get("type").getAsString() : ""; + // Handle thinking_done which has no content field + if (type.equals("thinking_done")) { + if (isThinkingBlockOpen) { + eventHandler.accept(THINKING_BLOCK_SUFFIX + "\n"); + isThinkingBlockOpen = false; + } + continue; + } + if (eventObj.has("content")) { String content = eventObj.get("content").getAsString(); @@ -308,10 +332,14 @@ public void streamMultiChatResponse(List chatHashes, String question, Li // Format metadata in italics for the UI, with explicit 'status' indication eventHandler.accept("\n_**[Status]:** " + content + "_\n"); } else if (type.equals("thinking")) { - // Emit a dedicated block marker to avoid markdown parsing conflicts - eventHandler.accept("\n" + THINKING_BLOCK_PREFIX + content + THINKING_BLOCK_SUFFIX + "\n"); - } else if (type.equals("final")) { + if (!isThinkingBlockOpen) { + // Emit a dedicated block marker to avoid markdown parsing conflicts + eventHandler.accept("\n" + THINKING_BLOCK_PREFIX); + isThinkingBlockOpen = true; + } + eventHandler.accept(content); + } else if (type.equals("final")) { // Check the flag before appending if (isFirstFinalToken) { eventHandler.accept("\n" + content); // Add newline for the first word diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java index 10fd3d4409..1130e1cc0c 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java @@ -493,9 +493,11 @@ private void appendThinkingBlock(int lineStart, String content, SimpleAttributeS chatDocument.insertString(chatDocument.getLength(), headerText, headerStyle); if (expanded && content != null && !content.isEmpty()) { - AttributeSet bodyStyle = combineStyles(createBaseStyle(false), chatArea.getStyle("thinking-body")); - appendInlineMarkdown(content, bodyStyle); - chatDocument.insertString(chatDocument.getLength(), "\n", bodyStyle); + // Create a container with thinking-body styling and render markdown + SimpleAttributeSet thinkingContainer = new SimpleAttributeSet(); + StyleConstants.setBackground(thinkingContainer, StyleConstants.getBackground(chatArea.getStyle("thinking-body"))); + StyleConstants.setForeground(thinkingContainer, StyleConstants.getForeground(chatArea.getStyle("thinking-body"))); + appendMarkdown(content, thinkingContainer); } applyContainerAttributes(lineStart, container); From 1884239f26f4db2fbc247a9d9a7e954880c01523 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Fri, 8 May 2026 15:33:22 -0300 Subject: [PATCH 075/143] feat: integrate sidebar selection --- .../iped/app/ui/ai/AIChatCoordinator.java | 53 +++++++++++-- .../iped/app/ui/ai/view/AIAssistantPanel.java | 79 +++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index e3587bfa59..5af1403bb9 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -6,7 +6,10 @@ import iped.app.ui.ai.util.AIWhatsappChatExtractor; import iped.app.ui.ai.util.AIPayloadFactory; import iped.app.ui.ai.model.ContextFileEntry; +import iped.app.ui.ai.model.AIChatMessage; +import iped.app.ui.ai.model.Conversation; import iped.app.ui.ai.context.AIContextManager; +import iped.app.ui.ai.context.ConversationManager; import iped.data.IItem; import java.util.ArrayList; @@ -44,8 +47,8 @@ public AIChatCoordinator(AIBackendService backendService) { } /** - * Executes the complete AI request pipeline: validates the selected file, - * uploads the context (if necessary), and streams the AI's response. + * Executes the complete AI request pipeline: validates the selected files, + * initializes or reuses the chat context (if unchanged), and streams the AI's response. * @param question The text prompt from the user * @param uiCallback A callback used to push status updates and streamed text back to the UI * @param onComplete A callback triggered when the entire process finishes (success or fail), @@ -60,6 +63,8 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o .filter(ContextFileEntry::isValidForContext) .collect(Collectors.toList()); + // If a question is typed before the background thread finishes restoring the UI, + // this safely blocks the user for that fraction of a second. if (validEntries.isEmpty()) { onError.accept("Please, add at least one valid file to context before asking."); return; @@ -69,6 +74,8 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o List newContextIds = validEntries.stream() .map(e -> e.getItem().getId()) .collect(Collectors.toList()); + + // Since the UI was restored, this evaluates to false boolean contextChanged = !newContextIds.equals(currentContextItemIds); // Offload heavy lifting to a background thread @@ -95,24 +102,34 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o // Update cache state currentContextItemIds = newContextIds; + + // Save the backend state to the Conversation Manager so it persists + Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); + if (activeConv != null) { + activeConv.setContextIds(new ArrayList<>(currentContextItemIds)); + activeConv.setChatHashes(new ArrayList<>(currentChatHashes)); + activeConv.updateLastModified(); + } } // Step B: Stream the response StringBuilder fullResponse = new StringBuilder(); - + // Route to the correct streaming endpoint - if (validEntries.size() == 1) { + if (currentChatHashes.size() == 1) { // Single chat stream backendService.streamChatResponse(currentChatHashes.get(0), question, chatHistory, token -> { uiCallback.accept(token); fullResponse.append(token); }); - } else { + } else if (currentChatHashes.size() > 1) { // Multi chat stream backendService.streamMultiChatResponse(currentChatHashes, question, chatHistory, token -> { uiCallback.accept(token); fullResponse.append(token); }); + } else { + throw new IllegalStateException("Cannot stream response: No active chat hashes found."); } uiCallback.accept("\n\n"); @@ -140,4 +157,30 @@ public void clearHistory() { currentChatHashes.clear(); currentContextItemIds.clear(); } + + /** + * Hydrates the coordinator's memory with historical state, allowing the LLM + * to resume a previous conversation seamlessly without re-uploading files. + */ + public void loadHistoricalContext(List hashes, List itemIds, List uiMessages) { + // Restore backend session hashes + this.currentChatHashes.clear(); + if (hashes != null) this.currentChatHashes.addAll(hashes); + + // Restore IPED context IDs + this.currentContextItemIds.clear(); + if (itemIds != null) this.currentContextItemIds.addAll(itemIds); + + // Restore the LLM conversation memory + this.chatHistory.clear(); + if (uiMessages != null) { + for (AIChatMessage msg : uiMessages) { + // The backend only wants to remember user and assistant turns. + // We do NOT send "system" or "error" messages back to the LLM. + if ("user".equals(msg.getType()) || "assistant".equals(msg.getType())) { + this.chatHistory.add(new AIStreamChatRequest.AIMessage(msg.getType(), msg.getContent())); + } + } + } + } } \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 02a4d264be..665c1b1c2c 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -364,6 +364,25 @@ private JPanel createSidebarPanel() { return label; }); + // Wire up the click listener for the sidebar tabs + conversationList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() != MouseEvent.BUTTON1) return; + + int index = conversationList.locationToIndex(e.getPoint()); + if (index < 0) return; + + Conversation selected = conversationListModel.getElementAt(index); + Conversation active = ConversationManager.getInstance().getActiveConversation(); + + // Only load if they clicked a different conversation + if (selected != null && (active == null || !active.getId().equals(selected.getId()))) { + loadConversation(selected); + } + } + }); + // Hide the scrollpane borders to blend seamlessly into the sidebar JScrollPane scrollPane = new JScrollPane(conversationList); scrollPane.setBorder(BorderFactory.createEmptyBorder()); @@ -410,6 +429,66 @@ private void startNewChat() { inputArea.requestFocusInWindow(); } + /** + * Loads a selected conversation from the sidebar into the main chat window. + */ + private void loadConversation(Conversation conv) { + // Update State Manager + ConversationManager.getInstance().setActiveConversation(conv); + + // Hydrate the Coordinator's Network Memory + if (coordinator != null) { + coordinator.loadHistoricalContext(conv.getChatHashes(), conv.getContextIds(), conv.getMessages()); + } + + // Restore the visual IPED Context UI + AIContextManager.getInstance().clearContext(); // Wipe previous chat's files + + new Thread(() -> { + List restoredItems = new ArrayList<>(); + + if (conv.getContextIds() != null) { + for (Integer itemId : conv.getContextIds()) { + try { + // Use the searcher to find the item across all loaded evidence sources + IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "id:" + itemId); + MultiSearchResult result = searcher.multiSearch(); + + if (result != null && result.getLength() > 0) { + // Extract the fully qualified IItemId (which contains the source routing) + IItemId qualifiedItemId = result.getItem(0); + + // Now the MultiSource can safely fetch the item! + IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); + if (item != null) { + restoredItems.add(item); + } + } + } catch (Exception e) { + System.err.println("Could not restore context item ID: " + itemId); + } + } + } + + // Push the items back into the visual sidebar safely on the UI thread + if (!restoredItems.isEmpty()) { + SwingUtilities.invokeLater(() -> { + AIContextManager.getInstance().addContextFiles(restoredItems); + }); + } + }).start(); + + // Reset UI streaming states + draftMessage = null; + if (markdownRenderer != null) { + markdownRenderer.commitDraft(); + } + + // Redraw the screen + refreshChatArea(); + conversationList.setSelectedValue(conv, true); + } + private JPanel createContextSection() { contextListModel = new DefaultListModel<>(); contextList = new JList<>(contextListModel); From fae333615c6389d898d6fe180524d2e1e94de8d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Fri, 8 May 2026 16:38:20 -0300 Subject: [PATCH 076/143] fix: fix exibition bug --- .../app/ui/ai/view/AIMarkdownRenderer.java | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java index 1130e1cc0c..60ba51bac6 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java @@ -217,6 +217,7 @@ private void configureChatStyles() { Style heading = chatArea.addStyle("heading", base); StyleConstants.setBold(heading, true); StyleConstants.setFontSize(heading, 14); + StyleConstants.setForeground(heading, new java.awt.Color(0x1a, 0x56, 0xb3)); chatStyles.put("heading", heading); Style bold = chatArea.addStyle("bold", base); @@ -297,7 +298,7 @@ private void appendMessageHeader(AIChatMessage message, SimpleAttributeSet conta /** * Writes message content using the markdown-aware renderer. */ - private void appendMessageContent(String content, SimpleAttributeSet container) throws BadLocationException { + private void appendMessageContent(String content, AttributeSet container) throws BadLocationException { appendMarkdown(content == null ? "" : content, container); } @@ -311,10 +312,10 @@ private void appendMessageSeparator() throws BadLocationException { /** * Renders markdown line by line and applies paragraph/container attributes. */ - private void appendMarkdown(String markdown, SimpleAttributeSet container) throws BadLocationException { + private void appendMarkdown(String markdown, AttributeSet container) throws BadLocationException { String normalizedMarkdown = markdown.replace("\r\n", "\n").replace('\r', '\n'); - AttributeSet normalBaseStyle = createBaseStyle(false); - AttributeSet italicBaseStyle = createBaseStyle(true); + AttributeSet normalBaseStyle = combineStyles(createBaseStyle(false), container); + AttributeSet italicBaseStyle = combineStyles(createBaseStyle(true), container); String[] lines = normalizedMarkdown.split("\n", -1); boolean inUnderscoreItalicBlock = false; boolean inThinkingBlock = false; @@ -480,7 +481,7 @@ private void appendMarkdown(String markdown, SimpleAttributeSet container) throw /** * Renders a collapsible thinking block with a clickable header. */ - private void appendThinkingBlock(int lineStart, String content, SimpleAttributeSet container) throws BadLocationException { + private void appendThinkingBlock(int lineStart, String content, AttributeSet container) throws BadLocationException { int blockId = nextThinkingBlockId++; boolean expanded = thinkingExpandedState.getOrDefault(blockId, Boolean.TRUE); @@ -493,14 +494,9 @@ private void appendThinkingBlock(int lineStart, String content, SimpleAttributeS chatDocument.insertString(chatDocument.getLength(), headerText, headerStyle); if (expanded && content != null && !content.isEmpty()) { - // Create a container with thinking-body styling and render markdown - SimpleAttributeSet thinkingContainer = new SimpleAttributeSet(); - StyleConstants.setBackground(thinkingContainer, StyleConstants.getBackground(chatArea.getStyle("thinking-body"))); - StyleConstants.setForeground(thinkingContainer, StyleConstants.getForeground(chatArea.getStyle("thinking-body"))); + AttributeSet thinkingContainer = combineStyles(createBaseStyle(false), chatArea.getStyle("thinking-body")); appendMarkdown(content, thinkingContainer); } - - applyContainerAttributes(lineStart, container); } /** @@ -548,7 +544,7 @@ private String removeCharAt(String text, int index) { /** * Applies container attributes to both paragraph and character ranges. */ - private void applyContainerAttributes(int start, SimpleAttributeSet container) throws BadLocationException { + private void applyContainerAttributes(int start, AttributeSet container) throws BadLocationException { chatDocument.setParagraphAttributes(start, chatDocument.getLength() - start, container, false); chatDocument.setCharacterAttributes(start, chatDocument.getLength() - start, container, false); } @@ -771,8 +767,9 @@ private String resolveTokenVisibleText(String hash, String fallbackVisibleText) * Writes a chunk token using a dedicated clickable style and embedded metadata. */ private void appendToken(String visibleText, String hash, String chunkId, AttributeSet baseStyle) throws BadLocationException { - SimpleAttributeSet tokenStyle = new SimpleAttributeSet(chatArea.getStyle("token")); + SimpleAttributeSet tokenStyle = new SimpleAttributeSet(); tokenStyle.addAttributes(baseStyle); + tokenStyle.addAttributes(chatArea.getStyle("token")); tokenStyle.addAttribute(TOKEN_ATTRIBUTE, Boolean.TRUE); tokenStyle.addAttribute(TOKEN_HASH_ATTRIBUTE, hash); tokenStyle.addAttribute(TOKEN_CHUNK_ID_ATTRIBUTE, chunkId); From d3b9c77144959d57856b41a7a36041e184de10ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Mon, 11 May 2026 14:58:40 -0300 Subject: [PATCH 077/143] format final answer block --- .../app/ui/ai/backend/AIBackendClient.java | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index 950bf20913..35b882d22f 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -16,6 +16,8 @@ import java.util.function.Consumer; import java.util.List; import java.util.ArrayList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Concrete implementation of {@link AIBackendService} responsible for handling @@ -100,7 +102,7 @@ public String initChat(String chatHtml) throws AIBackendException { // Parse the JSON response JsonObject responseJson = JsonParser.parseString(response.body()).getAsJsonObject(); - + // Check backend level error if (responseJson.has("error")) { throw new AIBackendException("Backend Error: " + responseJson.get("error").getAsString()); @@ -202,10 +204,10 @@ public void streamChatResponse(String chatHash, String question, List chatHashes, String question, Li if (response.statusCode() != 200) { throw new AIBackendException("Backend returned HTTP " + response.statusCode()); } - + // The SSE parsing logic is identical to the single-chat stream try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { String line; @@ -342,11 +344,11 @@ public void streamMultiChatResponse(List chatHashes, String question, Li } else if (type.equals("final")) { // Check the flag before appending if (isFirstFinalToken) { - eventHandler.accept("\n" + content); // Add newline for the first word - isFirstFinalToken = false; // Turn the flag off + eventHandler.accept("\n**[FINAL ANSWER]:**\n" + content); + isFirstFinalToken = false; } else { - eventHandler.accept(content); // Print normally for the rest - } + eventHandler.accept(content); + } } else if (type.equals("error")) { throw new AIBackendException("Backend Streaming Error: " + content); } From a0105600af840009669616517ec0e7e32084fa97 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 11 May 2026 15:32:20 -0300 Subject: [PATCH 078/143] feat: removes "Clear Chat History" button and adds a clickable remove button on each conversation in the list inside the side pannel --- .../ui/ai/context/ConversationManager.java | 8 +- .../iped/app/ui/ai/view/AIAssistantPanel.java | 123 +++++++++++------- 2 files changed, 79 insertions(+), 52 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java index 482ebaafcd..b53f833a46 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java @@ -39,8 +39,8 @@ public Conversation getActiveConversation() { */ public void setActiveConversation(Conversation conversation) { this.activeConversation = conversation; - if (!conversations.contains(conversation)) { - // Add new conversations to the top of the list + // Safeguard: don't add null to the list if the active state is wiped + if (conversation != null && !conversations.contains(conversation)) { conversations.add(0, conversation); } } @@ -58,6 +58,10 @@ public List getConversations() { return Collections.unmodifiableList(conversations); } + public void removeConversation(Conversation conversation) { + conversations.remove(conversation); + } + /** * Appends a message to the active conversation and auto-generates a title if needed. */ diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 665c1b1c2c..464de2d474 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -351,17 +351,27 @@ private JPanel createSidebarPanel() { // Custom Renderer to make the items look like modern chat tabs conversationList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { - JLabel label = (JLabel) new DefaultListCellRenderer() - .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); - + JPanel rowPanel = new JPanel(new BorderLayout(8, 0)); + rowPanel.setBorder(BorderFactory.createEmptyBorder(8, 10, 8, 10)); // Padded + rowPanel.setOpaque(true); + rowPanel.setBackground(isSelected ? list.getSelectionBackground() : list.getBackground()); + if (value instanceof Conversation) { Conversation conv = (Conversation) value; - label.setText(conv.getTitle()); - // Add padding to make the row taller and easier to click - label.setBorder(BorderFactory.createEmptyBorder(8, 10, 8, 10)); + JLabel textLabel = new JLabel(conv.getTitle()); + textLabel.setFont(list.getFont()); + textLabel.setForeground(isSelected ? list.getSelectionForeground() : list.getForeground()); + + JLabel removeLabel = new JLabel("X"); + removeLabel.setFont(list.getFont().deriveFont(Font.BOLD)); + // Only show red 'X' if selected, otherwise subtle gray, to keep UI clean + removeLabel.setForeground(isSelected ? new Color(160, 0, 0) : Color.LIGHT_GRAY); + + rowPanel.add(textLabel, BorderLayout.CENTER); + rowPanel.add(removeLabel, BorderLayout.EAST); } - return label; + return rowPanel; }); // Wire up the click listener for the sidebar tabs @@ -373,9 +383,20 @@ public void mouseClicked(MouseEvent e) { int index = conversationList.locationToIndex(e.getPoint()); if (index < 0) return; + Rectangle cellBounds = conversationList.getCellBounds(index, index); + if (cellBounds == null || !cellBounds.contains(e.getPoint())) return; + Conversation selected = conversationListModel.getElementAt(index); - Conversation active = ConversationManager.getInstance().getActiveConversation(); + // Check if clicked the 'X' hotzone + if (e.getX() >= cellBounds.x + cellBounds.width - 28) { + promptDeleteConversation(selected); + return; + } + + // Otherwise, load the conversation + Conversation active = ConversationManager.getInstance().getActiveConversation(); + // Only load if they clicked a different conversation if (selected != null && (active == null || !active.getId().equals(selected.getId()))) { loadConversation(selected); @@ -392,6 +413,44 @@ public void mouseClicked(MouseEvent e) { return panel; } + /** + * Confirmation to delete chat when clicking the 'X' on the Conversation list entry + */ + private void promptDeleteConversation(Conversation conv) { + int confirm = JOptionPane.showConfirmDialog(frame, + "Are you sure you want to delete this chat?\n\"" + conv.getTitle() + "\"", + "Delete Chat", + JOptionPane.YES_NO_OPTION, + JOptionPane.WARNING_MESSAGE); + + if (confirm == JOptionPane.YES_OPTION) { + // Remove from memory + ConversationManager.getInstance().removeConversation(conv); + + // Check if the chat currently being looked at was deleted + Conversation active = ConversationManager.getInstance().getActiveConversation(); + if (active == null || active.getId().equals(conv.getId())) { + List remaining = ConversationManager.getInstance().getConversations(); + + if (!remaining.isEmpty()) { + // Option A: Load the conversation at the top of the list + loadConversation(remaining.get(0)); + refreshSidebarList(); + } else { + // Option B: The list is completely empty + ConversationManager.getInstance().setActiveConversation(null); + clearChatScreenAndMemory(); + AIContextManager.getInstance().clearContext(); + refreshSidebarList(); + } + } else { + refreshSidebarList(); // Just removes it visually from the sidebar + } + + // TODO: add the code here to delete the actual JSON file from the disk, when that is available + } + } + private void toggleSidebar() { if (sidebarPanel.isVisible()) { sidebarPanel.setVisible(false); @@ -680,51 +739,9 @@ private JPanel createTasksPanel() { panel.add(Box.createVerticalStrut(5)); } - // Add separator - panel.add(Box.createVerticalStrut(10)); - JSeparator separator = new JSeparator(); - separator.setMaximumSize(new Dimension(200, 1)); - panel.add(separator); - panel.add(Box.createVerticalStrut(5)); - - // Clear Chat History button - JButton clearChatButton = new JButton("Clear Chat History"); - clearChatButton.setAlignmentX(Component.CENTER_ALIGNMENT); - clearChatButton.setMaximumSize(new Dimension(200, 30)); - clearChatButton.addActionListener(e -> clearCurrentChat()); - panel.add(clearChatButton); - return panel; } - /** - * Triggered by the Quick Actions panel. Erases the current conversation's history. - * Permanently erases the messages in this specific chat - */ - private void clearCurrentChat() { - Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); - - // Reset the title so the next user message triggers the title auto-generator - activeConv.setTitle("New Conversation"); - activeConv.updateLastModified(); - - // Delete the data from the Active Conversation model - activeConv.getMessages().clear(); - - // Wipe the backend session markers so it truly starts fresh - activeConv.getChatHashes().clear(); - activeConv.getContextIds().clear(); - - // Wipe the screen and coordinator memory - clearChatScreenAndMemory(); - - refreshSidebarList(); - refreshChatArea(); - - addMessage("System", "Current chat history cleared."); - inputArea.requestFocusInWindow(); - } - /** * Clears the UI and the Coordinator's memory. * Does NOT delete the saved messages in the ConversationManager. @@ -1025,6 +1042,12 @@ private void handleSendAction() { return; } + // If the user deleted all chats and types in the blank slate, start a new one + if (ConversationManager.getInstance().getActiveConversation() == null) { + ConversationManager.getInstance().startNewConversation(); + refreshSidebarList(); + } + // Print user message immediately addMessage("You", text, "user"); inputArea.setText(""); From a9e56ee894a4401d6c57a29fe70113cbbff02278 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 11 May 2026 15:57:52 -0300 Subject: [PATCH 079/143] fix: Update loadConversation to search for items using their MD5 hash rather than item ID --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 464de2d474..7ab4acd2a7 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -506,25 +506,25 @@ private void loadConversation(Conversation conv) { new Thread(() -> { List restoredItems = new ArrayList<>(); - if (conv.getContextIds() != null) { - for (Integer itemId : conv.getContextIds()) { + // Use the MD5 Chat Hashes to find the file + if (conv.getChatHashes() != null) { + for (String hash : conv.getChatHashes()) { try { - // Use the searcher to find the item across all loaded evidence sources - IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "id:" + itemId); + IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); MultiSearchResult result = searcher.multiSearch(); if (result != null && result.getLength() > 0) { // Extract the fully qualified IItemId (which contains the source routing) IItemId qualifiedItemId = result.getItem(0); - - // Now the MultiSource can safely fetch the item! + + // Now the MultiSource can safely fetch the item IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); if (item != null) { restoredItems.add(item); } } } catch (Exception e) { - System.err.println("Could not restore context item ID: " + itemId); + System.err.println("Could not restore context item hash: " + hash); } } } @@ -532,6 +532,24 @@ private void loadConversation(Conversation conv) { // Push the items back into the visual sidebar safely on the UI thread if (!restoredItems.isEmpty()) { SwingUtilities.invokeLater(() -> { + // Check race condition: Did the user click a different chat while the search is ongoing? + Conversation currentActive = ConversationManager.getInstance().getActiveConversation(); + if (currentActive == null || !currentActive.getId().equals(conv.getId())) { + return; // Abort + } + + // Update the Coordinator's memory with the freshly fetched IDs just in case + // the database was rebuilt and the integer IDs changed + List freshIds = restoredItems.stream() + .map(IItem::getId) + .collect(java.util.stream.Collectors.toList()); + + if (coordinator != null) { + // Re-sync the coordinator to prevent a false "contextChanged" flag + coordinator.loadHistoricalContext(conv.getChatHashes(), freshIds, conv.getMessages()); + } + + // Restore the visual UI AIContextManager.getInstance().addContextFiles(restoredItems); }); } From b24aed7ab95f9d1a7dc4e8b50c937ae430116e77 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 12 May 2026 08:06:01 -0300 Subject: [PATCH 080/143] feat: remove "analyze metadata" button from quick actions pannel --- .../src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index eb20b7b7b4..9b62ed9bb7 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -744,9 +744,8 @@ private JPanel createTasksPanel() { Map taskPrompts = new java.util.HashMap<>(); taskPrompts.put("Summarize", "Resuma o arquivo fornecido."); taskPrompts.put("Find Patterns", "Encontre padrões no arquivo fornecido."); - taskPrompts.put("Analyze Metadata", "Analise os metadados do arquivo fornecido."); - String[] tasks = {"Summarize", "Find Patterns", "Analyze Metadata"}; + String[] tasks = {"Summarize", "Find Patterns"}; for (String task : tasks) { JButton btn = new JButton(task); btn.setAlignmentX(Component.CENTER_ALIGNMENT); From ec9f2b7039bec9301eaabb20ed80e566a0b0a8e3 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 12 May 2026 10:44:21 -0300 Subject: [PATCH 081/143] feat: implement conversation persistence layer --- .../ui/ai/util/ConversationPersistence.java | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java b/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java new file mode 100644 index 0000000000..022e83e05a --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java @@ -0,0 +1,121 @@ +package iped.app.ui.ai.util; + +import iped.engine.data.IPEDMultiSource; +import iped.app.ui.App; +import iped.app.ui.ai.model.Conversation; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Handles saving and loading AI Chat conversations to/from the local IPED case directory. + * Ensures strict forensic isolation so chats do not bleed between different cases. + */ +public class ConversationPersistence { + + // Subfolder inside the case's iped/data directory + private static final String CHATS_DIR_NAME = "iped/data/ai_chats"; + + // Pretty printing makes the JSON human-readable + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + /** + * Resolves the case-isolated directory for storing AI chats + * Creates the directory if it does not exist + * Safely handles IPEDMultiSource (Multi-case mode) by falling back to the first available case + */ + private static File getStorageDirectory() { + if (App.get() == null || App.get().appCase == null) { + return null; // Failsafe if accessed outside of an open case + } + + File caseDir = App.get().appCase.getCaseDir(); + + // Handle the Multi-Case edge case where the global case directory might be null + if (caseDir == null && App.get().appCase instanceof IPEDMultiSource) { + IPEDMultiSource multiSource = (IPEDMultiSource) App.get().appCase; + if (!multiSource.getAtomicSources().isEmpty()) { + // Fallback: Save the multi-case chats into the first case's directory + caseDir = multiSource.getAtomicSources().get(0).getCaseDir(); + } + } + + // Absolute failsafe if the directory is still somehow unresolvable + if (caseDir == null) { + caseDir = new File(System.getProperty("user.home"), ".iped_ai_chats"); + } + + File chatsDir = new File(caseDir, CHATS_DIR_NAME); + + if (!chatsDir.exists()) { + chatsDir.mkdirs(); + } + + return chatsDir; + } + + /** + * Serializes a Conversation object to a distinct JSON file + */ + public static void saveConversation(Conversation conversation) { + File dir = getStorageDirectory(); + if (dir == null || conversation == null) return; + + File chatFile = new File(dir, "chat_" + conversation.getId() + ".json"); + + try (FileWriter writer = new FileWriter(chatFile)) { + GSON.toJson(conversation, writer); + } catch (IOException e) { + System.err.println("Failed to save AI conversation: " + e.getMessage()); + } + } + + /** + * Reads all JSON files in the case's chat directory and returns them sorted by newest first. + */ + public static List loadAllConversations() { + List conversations = new ArrayList<>(); + File dir = getStorageDirectory(); + + if (dir == null || !dir.exists()) return conversations; + + File[] files = dir.listFiles((d, name) -> name.startsWith("chat_") && name.endsWith(".json")); + if (files != null) { + for (File file : files) { + try (FileReader reader = new FileReader(file)) { + Conversation conv = GSON.fromJson(reader, Conversation.class); + if (conv != null) { + conversations.add(conv); + } + } catch (Exception e) { + System.err.println("Failed to load AI conversation file " + file.getName() + ": " + e.getMessage()); + } + } + } + + // Sort by last modified, newest first, so the sidebar is ordered correctly + conversations.sort(Comparator.comparingLong(Conversation::getLastModified).reversed()); + return conversations; + } + + /** + * Deletes the JSON file associated with the given conversation ID. + */ + public static void deleteConversation(String conversationId) { + File dir = getStorageDirectory(); + if (dir == null || conversationId == null) return; + + File chatFile = new File(dir, "chat_" + conversationId + ".json"); + if (chatFile.exists()) { + chatFile.delete(); + } + } +} \ No newline at end of file From c25d5dbaa7e2f1e3750b91fe6b73c979b3ef2515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Tue, 12 May 2026 15:14:42 -0300 Subject: [PATCH 082/143] when user tries to add new items to context but llm has already answered, questionbox opens to confirm further actions --- .../src/main/java/iped/app/ui/MenuClass.java | 71 +++++++++++++------ .../iped/app/ui/ai/model/Conversation.java | 12 ++++ .../iped/app/ui/ai/view/AIAssistantPanel.java | 46 ++++++++++++ 3 files changed, 109 insertions(+), 20 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/MenuClass.java b/iped-app/src/main/java/iped/app/ui/MenuClass.java index 024571c01f..ea72868943 100644 --- a/iped-app/src/main/java/iped/app/ui/MenuClass.java +++ b/iped-app/src/main/java/iped/app/ui/MenuClass.java @@ -31,9 +31,12 @@ import javax.swing.JRadioButtonMenuItem; import javax.swing.JTable; import javax.swing.KeyStroke; +import javax.swing.JOptionPane; import javax.swing.SwingUtilities; import iped.app.ui.ai.context.AIContextManager; +import iped.app.ui.ai.context.ConversationManager; +import iped.app.ui.ai.model.Conversation; import iped.app.ui.ai.view.AIAssistantPanel; import iped.app.ui.themes.Theme; import iped.app.ui.themes.ThemeManager; @@ -343,19 +346,15 @@ public void actionPerformed(ActionEvent e) { } catch (Exception ignored) {} addAllHighlightedToAIContext.addActionListener(e -> { - new Thread(() -> { - List itemsToAdd = getHighlightedItems(); - - // Fallback: If no table rows are highlighted, or user clicked from the tree, use the single right-clicked item - if (itemsToAdd.isEmpty() && item != null) { - itemsToAdd.add(item); - } - - if (!itemsToAdd.isEmpty()) { - AIContextManager.getInstance().addContextFiles(itemsToAdd); - SwingUtilities.invokeLater(() -> AIAssistantPanel.getInstance().showPanel()); - } - }).start(); + List itemsToAdd = getHighlightedItems(); + + if (itemsToAdd.isEmpty() && item != null) { + itemsToAdd.add(item); + } + + if (!itemsToAdd.isEmpty()) { + openAIAssistantWithItems(itemsToAdd); + } }); this.add(addAllHighlightedToAIContext); @@ -366,13 +365,11 @@ public void actionPerformed(ActionEvent e) { } catch (Exception ignored) {} addAllCheckedToAIContext.addActionListener(e -> { - new Thread(() -> { - List itemsToAdd = getCheckedItems(); - if (!itemsToAdd.isEmpty()) { - AIContextManager.getInstance().addContextFiles(itemsToAdd); - SwingUtilities.invokeLater(() -> AIAssistantPanel.getInstance().showPanel()); - } - }).start(); + List itemsToAdd = getCheckedItems(); + + if (!itemsToAdd.isEmpty()) { + openAIAssistantWithItems(itemsToAdd); + } }); this.add(addAllCheckedToAIContext); @@ -431,6 +428,40 @@ private IItem resolveItemFromRow(JTable table, int viewRow) { return App.get().appCase.getItemByItemId(itemId); } + private void openAIAssistantWithItems(List itemsToAdd) { + Conversation activeConversation = ConversationManager.getInstance().getActiveConversation(); + AIAssistantPanel assistantPanel = AIAssistantPanel.getInstance(); + + if (assistantPanel.isProcessing()) { + JOptionPane.showMessageDialog( + null, + "Aguarde a resposta atual terminar antes de adicionar novos itens ao contexto.", + "AI Assistant", + JOptionPane.INFORMATION_MESSAGE); + return; + } + + if (activeConversation == null) { + assistantPanel.startNewConversationWithCurrentContext(itemsToAdd); + } else if (!activeConversation.hasAssistantReply()) { + AIContextManager.getInstance().addContextFiles(itemsToAdd); + assistantPanel.showPanel(); + } else { + int result = JOptionPane.showConfirmDialog( + null, + "Este contexto já foi usado em uma conversa respondida.\nQuer iniciar uma nova conversa usando os arquivos de contexto atuais?", + "Nova conversa", + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE); + + if (result == JOptionPane.YES_OPTION) { + assistantPanel.startNewConversationWithCurrentContext(itemsToAdd); + } else if (result == JOptionPane.NO_OPTION) { + assistantPanel.showPanel(); + } + } + } + public void addExportTreeMenuItems(JComponent menu) { exportTree = new JMenuItem(Messages.getString("MenuClass.ExportTree")); //$NON-NLS-1$ exportTree.addActionListener(menuListener); diff --git a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java index 7995d4c398..0439e2418a 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java +++ b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java @@ -41,6 +41,18 @@ public Conversation() { public List getMessages() { return messages; } public void setMessages(List messages) { this.messages = messages; } + /** + * Returns true when this conversation already has a completed assistant reply. + */ + public boolean hasAssistantReply() { + for (AIChatMessage msg : messages) { + if ("assistant".equals(msg.getType())) { + return true; + } + } + return false; + } + /** * Auto-generates a title based on the first user message if the title is default */ diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 665c1b1c2c..533c2b4a1d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -75,6 +75,7 @@ public class AIAssistantPanel { private Timer streamTimer; private AIChatMessage streamingMessage; private Runnable streamDrainAction; + private boolean processing; // Sidebar components private JSplitPane splitPane; @@ -134,6 +135,47 @@ public void contextChanged(ContextChangeEvent event) { }); } + public void startNewConversationWithCurrentContext(List pendingItems) { + Conversation newConversation = ConversationManager.getInstance().startNewConversation(); + + List contextIds = new ArrayList<>(); + for (IItem item : AIContextManager.getInstance().getContextFiles()) { + if (item != null && !contextIds.contains(item.getId())) { + contextIds.add(item.getId()); + } + } + + if (pendingItems != null) { + for (IItem item : pendingItems) { + if (item != null && !contextIds.contains(item.getId())) { + contextIds.add(item.getId()); + } + } + } + + newConversation.setContextIds(contextIds); + newConversation.setChatHashes(new ArrayList<>()); + newConversation.setMessages(new ArrayList<>()); + newConversation.updateLastModified(); + + if (pendingItems != null) { + AIContextManager.getInstance().addContextFiles(pendingItems); + } + + if (coordinator != null) { + coordinator.clearHistory(); + } + + refreshSidebarList(); + refreshChatArea(); + conversationList.setSelectedValue(newConversation, true); + showDialogSafely(); + } + + public boolean isProcessing() { + return processing; + } + private void createUI() { String title = "AI Assistant"; try { title = Messages.getString("AIAssistant.Title"); } catch (Exception e) {} @@ -929,6 +971,9 @@ private void ensureVisibleOnScreen() { private void showDialogSafely() { ensureVisibleOnScreen(); + if (frame.getExtendedState() == JFrame.ICONIFIED) { + frame.setExtendedState(JFrame.NORMAL); + } if (!frame.isVisible()) { frame.setVisible(true); } @@ -1084,6 +1129,7 @@ private void handleSendAction() { * Locks or unlocks the input fields and displays the loading bar. */ private void setProcessing(boolean processing) { + this.processing = processing; progressBar.setVisible(processing); sendButton.setEnabled(!processing); inputArea.setEnabled(!processing); From bd636da94dcb1e73a741e167bd1d10739f46fa13 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Wed, 13 May 2026 13:23:24 -0300 Subject: [PATCH 083/143] fix: translate new item to context question box popup to english --- iped-app/src/main/java/iped/app/ui/MenuClass.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/MenuClass.java b/iped-app/src/main/java/iped/app/ui/MenuClass.java index ea72868943..dafad20ec5 100644 --- a/iped-app/src/main/java/iped/app/ui/MenuClass.java +++ b/iped-app/src/main/java/iped/app/ui/MenuClass.java @@ -449,8 +449,8 @@ private void openAIAssistantWithItems(List itemsToAdd) { } else { int result = JOptionPane.showConfirmDialog( null, - "Este contexto já foi usado em uma conversa respondida.\nQuer iniciar uma nova conversa usando os arquivos de contexto atuais?", - "Nova conversa", + "This context has already been used in an active chat.\n Would you like to start a new conversation with both the previously uploaded files and the newly added ones?", + "New Chat", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); From d3477304fe4f94ecfede84dd3ffa1855320eed20 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Wed, 13 May 2026 14:20:01 -0300 Subject: [PATCH 084/143] feat: wire the persistence layer into the sidebar and assistant panel's method --- .../iped/app/ui/ai/AIChatCoordinator.java | 4 ++ .../ui/ai/context/ConversationManager.java | 21 +++++++- .../iped/app/ui/ai/model/Conversation.java | 15 ++++-- .../ui/ai/util/ConversationPersistence.java | 54 ++++++++++--------- .../iped/app/ui/ai/view/AIAssistantPanel.java | 21 +++++--- 5 files changed, 80 insertions(+), 35 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 5af1403bb9..841929e439 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -5,6 +5,7 @@ import iped.app.ui.ai.backend.AIStreamChatRequest; import iped.app.ui.ai.util.AIWhatsappChatExtractor; import iped.app.ui.ai.util.AIPayloadFactory; +import iped.app.ui.ai.util.ConversationPersistence; import iped.app.ui.ai.model.ContextFileEntry; import iped.app.ui.ai.model.AIChatMessage; import iped.app.ui.ai.model.Conversation; @@ -109,6 +110,9 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o activeConv.setContextIds(new ArrayList<>(currentContextItemIds)); activeConv.setChatHashes(new ArrayList<>(currentChatHashes)); activeConv.updateLastModified(); + + // Save the hydrated object to disk + ConversationPersistence.saveConversation(activeConv); } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java index b53f833a46..4ac2607979 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java @@ -2,6 +2,7 @@ import iped.app.ui.ai.model.AIChatMessage; import iped.app.ui.ai.model.Conversation; +import iped.app.ui.ai.util.ConversationPersistence; import java.util.ArrayList; import java.util.Collections; @@ -20,7 +21,19 @@ public class ConversationManager { private ConversationManager() { this.conversations = new ArrayList<>(); - startNewConversation(); + + // Load from disk + List loadedChats = ConversationPersistence.loadAllConversations(); + + if (loadedChats != null && !loadedChats.isEmpty()) { + this.conversations.addAll(loadedChats); + // Intentionally leave activeConversation as null so the screen + // starts blank, forcing the user to select one or click "+ New Chat" + this.activeConversation = null; + } else { + // If the folder is empty, start a fresh chat + startNewConversation(); + } } public static synchronized ConversationManager getInstance() { @@ -70,10 +83,14 @@ public void addMessageToActive(AIChatMessage message) { activeConversation.getMessages().add(message); activeConversation.updateLastModified(); - // If this is the first user message, generate the title! + // If this is the first user message, generate the title if ("New Conversation".equals(activeConversation.getTitle()) && "user".equals(message.getType())) { activeConversation.autoGenerateTitle(); } + + // Save to disk asynchronously + final Conversation convToSave = activeConversation; + new Thread(() -> ConversationPersistence.saveConversation(convToSave)).start(); } } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java index 0439e2418a..24463dc1ea 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java +++ b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java @@ -32,13 +32,22 @@ public Conversation() { public long getLastModified() { return lastModified; } public void updateLastModified() { this.lastModified = System.currentTimeMillis(); } - public List getContextIds() { return contextIds; } + public List getContextIds() { + if (contextIds == null) contextIds = new ArrayList<>(); + return contextIds; + } public void setContextIds(List contextIds) { this.contextIds = contextIds; } - public List getChatHashes() { return chatHashes; } + public List getChatHashes() { + if (chatHashes == null) chatHashes = new ArrayList<>(); + return chatHashes; + } public void setChatHashes(List chatHashes) { this.chatHashes = chatHashes; } - public List getMessages() { return messages; } + public List getMessages() { + if (messages == null) messages = new ArrayList<>(); + return messages; + } public void setMessages(List messages) { this.messages = messages; } /** diff --git a/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java b/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java index 022e83e05a..84db5d9ad2 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java +++ b/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java @@ -33,33 +33,39 @@ public class ConversationPersistence { * Safely handles IPEDMultiSource (Multi-case mode) by falling back to the first available case */ private static File getStorageDirectory() { - if (App.get() == null || App.get().appCase == null) { - return null; // Failsafe if accessed outside of an open case - } - - File caseDir = App.get().appCase.getCaseDir(); - - // Handle the Multi-Case edge case where the global case directory might be null - if (caseDir == null && App.get().appCase instanceof IPEDMultiSource) { - IPEDMultiSource multiSource = (IPEDMultiSource) App.get().appCase; - if (!multiSource.getAtomicSources().isEmpty()) { - // Fallback: Save the multi-case chats into the first case's directory - caseDir = multiSource.getAtomicSources().get(0).getCaseDir(); + try { + + if (App.get() == null || App.get().appCase == null) { + return null; // Failsafe if accessed outside of an open case + } + + File caseDir = App.get().appCase.getCaseDir(); + + // Handle the Multi-Case edge case where the global case directory might be null + if (caseDir == null && App.get().appCase instanceof IPEDMultiSource) { + IPEDMultiSource multiSource = (IPEDMultiSource) App.get().appCase; + if (!multiSource.getAtomicSources().isEmpty()) { + // Fallback: Save the multi-case chats into the first case's directory + caseDir = multiSource.getAtomicSources().get(0).getCaseDir(); + } + } + + // Absolute failsafe if the directory is still somehow unresolvable + if (caseDir == null) { + caseDir = new File(System.getProperty("user.home"), ".iped_ai_chats"); } - } - - // Absolute failsafe if the directory is still somehow unresolvable - if (caseDir == null) { - caseDir = new File(System.getProperty("user.home"), ".iped_ai_chats"); - } - File chatsDir = new File(caseDir, CHATS_DIR_NAME); - - if (!chatsDir.exists()) { - chatsDir.mkdirs(); + File chatsDir = new File(caseDir, CHATS_DIR_NAME); + + if (!chatsDir.exists()) { + chatsDir.mkdirs(); + } + + return chatsDir; + } catch (Exception e) { + System.err.println("Safe fallback: Could not resolve AI storage dir - " + e.getMessage()); + return null; } - - return chatsDir; } /** diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 9c2cc2b45f..2695dc93c8 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -28,6 +28,7 @@ import iped.app.ui.ai.context.ConversationManager; import iped.app.ui.ai.backend.AIBackendClient; import iped.app.ui.ai.backend.AIBackendConfig; +import iped.app.ui.ai.util.ConversationPersistence; import iped.app.ui.App; import iped.app.ui.Messages; @@ -241,6 +242,8 @@ private void createUI() { positionDialog(); addMessage("System", "AI Assistant ready. Connected to local Backend server.\nRight-click an HTML WhatsApp chat export to add it to the context, then type your question."); + + refreshSidebarList(); } private void installTokenClickHandler() { @@ -469,6 +472,9 @@ private void promptDeleteConversation(Conversation conv) { JOptionPane.WARNING_MESSAGE); if (confirm == JOptionPane.YES_OPTION) { + // Delete the actual JSON file from the hard drive + ConversationPersistence.deleteConversation(conv.getId()); + // Remove from memory ConversationManager.getInstance().removeConversation(conv); @@ -491,8 +497,6 @@ private void promptDeleteConversation(Conversation conv) { } else { refreshSidebarList(); // Just removes it visually from the sidebar } - - // TODO: add the code here to delete the actual JSON file from the disk, when that is available } } @@ -950,11 +954,16 @@ private void addMessage(String sender, String message) { addMessage(sender, message, "system"); } + // Renderable messages are the messages in the current active conversation private List buildRenderableMessages() { - // Renderable messages are the messages in the current active conversation - List renderableMessages = new ArrayList<>( - ConversationManager.getInstance().getActiveConversation().getMessages() - ); + List renderableMessages = new ArrayList<>(); + + // Safely check if there is an active conversation + Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); + if (activeConv != null) { + renderableMessages.addAll(activeConv.getMessages()); + } + if (draftMessage != null) { renderableMessages.add(draftMessage); } From 5934ca2580be10eed4eb786e390875eb8ac3d54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Wed, 13 May 2026 16:05:35 -0300 Subject: [PATCH 085/143] refactor: replace summary extraction logic with SummaryValueExtractor utility --- .../app/ui/ai/context/AIContextManager.java | 50 +---------- .../app/ui/ai/model/ContextFileEntry.java | 74 +-------------- .../app/ui/ai/util/SummaryValueExtractor.java | 89 +++++++++++++++++++ 3 files changed, 94 insertions(+), 119 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/util/SummaryValueExtractor.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java index 84ca0ea3c2..c46f43214c 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java @@ -7,6 +7,7 @@ import javax.swing.event.EventListenerList; import iped.app.ui.ai.model.ContextFileEntry; +import iped.app.ui.ai.util.SummaryValueExtractor; import iped.data.IItem; import iped.engine.lucene.analysis.CategoryTokenizer; import iped.parsers.standard.StandardParser; @@ -229,7 +230,7 @@ private String getRejectionReason(IItem item) { return "Rejected: Category is Empty Files."; } - if (hasSummary(item)) { + if (SummaryValueExtractor.hasSummary(item)) { return null; } @@ -313,53 +314,6 @@ private Boolean readCommunicationIsEmpty(IItem item) { return null; } - private boolean hasSummary(IItem item) { - if (item == null) { - return false; - } - - Object extraValue = item.getExtraAttribute(ExtraProperties.SUMMARY); - if (extraValue instanceof String) { - if (!((String) extraValue).trim().isEmpty()) { - return true; - } - } else if (extraValue instanceof java.util.Collection) { - for (Object value : (java.util.Collection) extraValue) { - if (value != null && !value.toString().trim().isEmpty()) { - return true; - } - } - } else if (extraValue instanceof Object[]) { - for (Object value : (Object[]) extraValue) { - if (value != null && !value.toString().trim().isEmpty()) { - return true; - } - } - } else if (extraValue instanceof String[]) { - for (String value : (String[]) extraValue) { - if (value != null && !value.trim().isEmpty()) { - return true; - } - } - } - - if (item.getMetadata() == null) { - return false; - } - - String[] values = item.getMetadata().getValues(ExtraProperties.SUMMARY); - if (values != null) { - for (String value : values) { - if (value != null && !value.trim().isEmpty()) { - return true; - } - } - } - - String single = item.getMetadata().get(ExtraProperties.SUMMARY); - return single != null && !single.trim().isEmpty(); - } - private String readFirstValue(IItem item, String key) { Object extra = item.getExtraAttribute(key); if (extra != null) { diff --git a/iped-app/src/main/java/iped/app/ui/ai/model/ContextFileEntry.java b/iped-app/src/main/java/iped/app/ui/ai/model/ContextFileEntry.java index 0e53852185..8492d1d406 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/model/ContextFileEntry.java +++ b/iped-app/src/main/java/iped/app/ui/ai/model/ContextFileEntry.java @@ -1,5 +1,6 @@ package iped.app.ui.ai.model; +import iped.app.ui.ai.util.SummaryValueExtractor; import iped.data.IItem; /** @@ -30,7 +31,7 @@ public class ContextFileEntry { * @param item the item to wrap */ public ContextFileEntry(IItem item) { - this(item, extractSummary(item), true, null); + this(item, SummaryValueExtractor.extractSummary(item), true, null); } /** @@ -58,76 +59,7 @@ private ContextFileEntry(IItem item, String summary, boolean validForContext, St * @return invalid context entry */ public static ContextFileEntry invalid(IItem item, String reason) { - return new ContextFileEntry(item, extractSummary(item), false, reason); - } - - /** - * Extracts the AI summary from the item's metadata. - */ - private static String extractSummary(IItem item) { - if (item == null) { - return null; - } - - Object extraValue = item.getExtraAttribute(iped.properties.ExtraProperties.SUMMARY); - if (extraValue instanceof String) { - String summary = ((String) extraValue).trim(); - if (!summary.isEmpty()) { - return summary; - } - } else if (extraValue instanceof java.util.Collection) { - StringBuilder sb = new StringBuilder(); - for (Object value : (java.util.Collection) extraValue) { - if (value != null) { - String text = value.toString().trim(); - if (!text.isEmpty()) { - if (sb.length() > 0) { - sb.append("\n"); - } - sb.append(text); - } - } - } - if (sb.length() > 0) { - return sb.toString(); - } - } else if (extraValue instanceof Object[]) { - StringBuilder sb = new StringBuilder(); - for (Object value : (Object[]) extraValue) { - if (value != null) { - String text = value.toString().trim(); - if (!text.isEmpty()) { - if (sb.length() > 0) { - sb.append("\n"); - } - sb.append(text); - } - } - } - if (sb.length() > 0) { - return sb.toString(); - } - } else if (extraValue instanceof String[]) { - String[] summaries = (String[]) extraValue; - if (summaries.length > 0) { - String joined = String.join("\n", summaries).trim(); - if (!joined.isEmpty()) { - return joined; - } - } - } - - if (item.getMetadata() == null) { - return null; - } - - String[] summaries = item.getMetadata().getValues(iped.properties.ExtraProperties.SUMMARY); - if (summaries != null && summaries.length > 0) { - // Join multiple summary parts if any - String joined = String.join("\n", summaries).trim(); - return joined.isEmpty() ? null : joined; - } - return null; + return new ContextFileEntry(item, SummaryValueExtractor.extractSummary(item), false, reason); } /** diff --git a/iped-app/src/main/java/iped/app/ui/ai/util/SummaryValueExtractor.java b/iped-app/src/main/java/iped/app/ui/ai/util/SummaryValueExtractor.java new file mode 100644 index 0000000000..e46ba8924f --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/util/SummaryValueExtractor.java @@ -0,0 +1,89 @@ +package iped.app.ui.ai.util; + +import iped.data.IItem; +import iped.properties.ExtraProperties; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * Normalizes the flexible storage formats used for AI summaries. + */ +public final class SummaryValueExtractor { + + private SummaryValueExtractor() { + } + + public static boolean hasSummary(IItem item) { + return !extractSummaryValues(item).isEmpty(); + } + + public static String extractSummary(IItem item) { + List summaries = extractSummaryValues(item); + if (summaries.isEmpty()) { + return null; + } + + String joined = String.join("\n", summaries).trim(); + return joined.isEmpty() ? null : joined; + } + + private static List extractSummaryValues(IItem item) { + List values = new ArrayList<>(); + if (item == null) { + return values; + } + + addValues(values, item.getExtraAttribute(ExtraProperties.SUMMARY)); + if (!values.isEmpty()) { + return values; + } + + if (item.getMetadata() == null) { + return values; + } + + String[] metadataValues = item.getMetadata().getValues(ExtraProperties.SUMMARY); + if (metadataValues != null) { + for (String value : metadataValues) { + addValue(values, value); + } + } + + if (values.isEmpty()) { + addValue(values, item.getMetadata().get(ExtraProperties.SUMMARY)); + } + + return values; + } + + private static void addValues(List values, Object extraValue) { + if (extraValue instanceof String) { + addValue(values, extraValue); + } else if (extraValue instanceof Collection) { + for (Object value : (Collection) extraValue) { + addValue(values, value); + } + } else if (extraValue instanceof Object[]) { + for (Object value : (Object[]) extraValue) { + addValue(values, value); + } + } else if (extraValue instanceof String[]) { + for (String value : (String[]) extraValue) { + addValue(values, value); + } + } + } + + private static void addValue(List values, Object value) { + if (value == null) { + return; + } + + String text = value.toString().trim(); + if (!text.isEmpty()) { + values.add(text); + } + } +} \ No newline at end of file From 4f70b88ecdd5e2041818df12f0dca31e4ea9a6b3 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 14 May 2026 13:20:22 -0300 Subject: [PATCH 086/143] fix: Preserve context in the question box popup when switching chats, even if no message was sent --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 2695dc93c8..d1faec029c 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -10,6 +10,7 @@ import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import javax.swing.*; import javax.swing.border.EmptyBorder; @@ -555,8 +556,9 @@ private void loadConversation(Conversation conv) { new Thread(() -> { List restoredItems = new ArrayList<>(); + // // PATH A: The chat was previously sent to the backend // Use the MD5 Chat Hashes to find the file - if (conv.getChatHashes() != null) { + if (conv.getChatHashes() != null && !conv.getChatHashes().isEmpty()) { for (String hash : conv.getChatHashes()) { try { IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); @@ -577,6 +579,28 @@ private void loadConversation(Conversation conv) { } } } + // PATH B: The chat is an unsent draft (Fallback to internal IPED Context IDs) + else if (conv.getContextIds() != null && !conv.getContextIds().isEmpty()) { + for (Integer itemId : conv.getContextIds()) { + try { + IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "id:" + itemId); + MultiSearchResult result = searcher.multiSearch(); + + if (result != null && result.getLength() > 0) { + // Extract the fully qualified IItemId (which contains the source routing) + IItemId qualifiedItemId = result.getItem(0); + + // Now the MultiSource can safely fetch the item + IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); + if (item != null) { + restoredItems.add(item); + } + } + } catch (Exception e) { + System.err.println("Could not restore context item ID: " + itemId); + } + } + } // Push the items back into the visual sidebar safely on the UI thread if (!restoredItems.isEmpty()) { @@ -591,7 +615,7 @@ private void loadConversation(Conversation conv) { // the database was rebuilt and the integer IDs changed List freshIds = restoredItems.stream() .map(IItem::getId) - .collect(java.util.stream.Collectors.toList()); + .collect(Collectors.toList()); if (coordinator != null) { // Re-sync the coordinator to prevent a false "contextChanged" flag From 6049eeb2849c838e4826eca2799fec02ea7a7aa6 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 14 May 2026 14:29:02 -0300 Subject: [PATCH 087/143] fix: Fixed the "No active chat hashes found" error by adding a needsInitialization flag in AIChatCoordinator.java. This ensures the chat context is properly initialized when switching back and forth between chats, resolving the issue where the error occurred if you switched chats before sending a new message. --- .../src/main/java/iped/app/ui/ai/AIChatCoordinator.java | 7 ++++--- .../main/java/iped/app/ui/ai/view/AIAssistantPanel.java | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 841929e439..f4011b1b85 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -76,14 +76,15 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o .map(e -> e.getItem().getId()) .collect(Collectors.toList()); - // Since the UI was restored, this evaluates to false + // Since the UI was restored, check if context changed OR if backend hashes are lacking boolean contextChanged = !newContextIds.equals(currentContextItemIds); + boolean needsInitialization = contextChanged || currentChatHashes.isEmpty(); // Offload heavy lifting to a background thread new Thread(() -> { try { - // Step A: Initialize the Chat (Only if context changed) - if (contextChanged) { + // Step A: Initialize the Chat + if (needsInitialization) { uiCallback.accept("**[System]:** Initializing context...\n\n"); chatHistory.clear(); currentChatHashes.clear(); diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index d1faec029c..7d09e3807f 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -556,7 +556,7 @@ private void loadConversation(Conversation conv) { new Thread(() -> { List restoredItems = new ArrayList<>(); - // // PATH A: The chat was previously sent to the backend + // PATH A: The chat was previously sent to the backend // Use the MD5 Chat Hashes to find the file if (conv.getChatHashes() != null && !conv.getChatHashes().isEmpty()) { for (String hash : conv.getChatHashes()) { From 754d18ad1b3c0f42f29cdf0ca13905bc466900b2 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 14 May 2026 15:42:00 -0300 Subject: [PATCH 088/143] fix: Prevented the context listener from reacting during chat switches --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 143 +++++++++++------- 1 file changed, 88 insertions(+), 55 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 7d09e3807f..227ed8aa3c 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -96,6 +96,7 @@ public class AIAssistantPanel { private JLabel contextEmptyLabel; private JButton clearContextButton; private TitledBorder contextBorder; + private boolean isSwitchingChats = false; // Prevents the ContextChangeListener from overwriting saved data during chat switching private static final class ContextSummaryRow { private final String text; @@ -132,7 +133,29 @@ private AIAssistantPanel() { AIContextManager.getInstance().addContextChangeListener(new ContextChangeListener() { @Override public void contextChanged(ContextChangeEvent event) { + // Update the Visual UI refreshContextUI(); + + // Abort if system is currently switching chats + if (isSwitchingChats) return; + + // Real-Time State Sync for Unsaved Drafts + Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); + if (activeConv != null) { + + // Grab all valid file IDs currently sitting in the UI bucket + List currentIds = AIContextManager.getInstance().getContextFiles().stream() + .map(IItem::getId) + .collect(Collectors.toList()); + + // Push them directly into the active conversation model + activeConv.setContextIds(currentIds); + activeConv.updateLastModified(); + + // Save the draft to disk asynchronously so it survives a sudden app crash + final Conversation convToSave = activeConv; + new Thread(() -> ConversationPersistence.saveConversation(convToSave)).start(); + } } }); } @@ -542,6 +565,10 @@ private void startNewChat() { * Loads a selected conversation from the sidebar into the main chat window. */ private void loadConversation(Conversation conv) { + + // Lock the listener + isSwitchingChats = true; + // Update State Manager ConversationManager.getInstance().setActiveConversation(conv); @@ -556,74 +583,80 @@ private void loadConversation(Conversation conv) { new Thread(() -> { List restoredItems = new ArrayList<>(); - // PATH A: The chat was previously sent to the backend - // Use the MD5 Chat Hashes to find the file - if (conv.getChatHashes() != null && !conv.getChatHashes().isEmpty()) { - for (String hash : conv.getChatHashes()) { - try { - IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); - MultiSearchResult result = searcher.multiSearch(); - - if (result != null && result.getLength() > 0) { - // Extract the fully qualified IItemId (which contains the source routing) - IItemId qualifiedItemId = result.getItem(0); - - // Now the MultiSource can safely fetch the item - IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); - if (item != null) { + try { + // PATH A: The chat was previously sent to the backend + // Use the MD5 Chat Hashes to find the file + if (conv.getChatHashes() != null && !conv.getChatHashes().isEmpty()) { + for (String hash : conv.getChatHashes()) { + try { + IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); + MultiSearchResult result = searcher.multiSearch(); + + if (result != null && result.getLength() > 0) { + // Extract the fully qualified IItemId (which contains the source routing) + IItemId qualifiedItemId = result.getItem(0); + + // Now the MultiSource can safely fetch the item + IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); + if (item != null) { restoredItems.add(item); + } } + } catch (Exception e) { + System.err.println("Could not restore context item hash: " + hash); } - } catch (Exception e) { - System.err.println("Could not restore context item hash: " + hash); } } - } - // PATH B: The chat is an unsent draft (Fallback to internal IPED Context IDs) - else if (conv.getContextIds() != null && !conv.getContextIds().isEmpty()) { - for (Integer itemId : conv.getContextIds()) { - try { - IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "id:" + itemId); - MultiSearchResult result = searcher.multiSearch(); - - if (result != null && result.getLength() > 0) { - // Extract the fully qualified IItemId (which contains the source routing) - IItemId qualifiedItemId = result.getItem(0); - - // Now the MultiSource can safely fetch the item - IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); - if (item != null) { + // PATH B: The chat is an unsent draft (Fallback to internal IPED Context IDs) + else if (conv.getContextIds() != null && !conv.getContextIds().isEmpty()) { + for (Integer itemId : conv.getContextIds()) { + try { + IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "id:" + itemId); + MultiSearchResult result = searcher.multiSearch(); + + if (result != null && result.getLength() > 0) { + // Extract the fully qualified IItemId (which contains the source routing) + IItemId qualifiedItemId = result.getItem(0); + + // Now the MultiSource can safely fetch the item + IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); + if (item != null) { restoredItems.add(item); + } } + } catch (Exception e) { + System.err.println("Could not restore context item ID: " + itemId); } - } catch (Exception e) { - System.err.println("Could not restore context item ID: " + itemId); } } - } - - // Push the items back into the visual sidebar safely on the UI thread - if (!restoredItems.isEmpty()) { + } finally { SwingUtilities.invokeLater(() -> { - // Check race condition: Did the user click a different chat while the search is ongoing? - Conversation currentActive = ConversationManager.getInstance().getActiveConversation(); - if (currentActive == null || !currentActive.getId().equals(conv.getId())) { - return; // Abort - } + try { + if (!restoredItems.isEmpty()) { + // Check race condition: Did the user click a different chat while the search is ongoing? + Conversation currentActive = ConversationManager.getInstance().getActiveConversation(); + if (currentActive == null || !currentActive.getId().equals(conv.getId())) { + return; // Abort + } - // Update the Coordinator's memory with the freshly fetched IDs just in case - // the database was rebuilt and the integer IDs changed - List freshIds = restoredItems.stream() - .map(IItem::getId) - .collect(Collectors.toList()); - - if (coordinator != null) { - // Re-sync the coordinator to prevent a false "contextChanged" flag - coordinator.loadHistoricalContext(conv.getChatHashes(), freshIds, conv.getMessages()); + // Update the Coordinator's memory with the freshly fetched IDs just in case + // the database was rebuilt and the integer IDs changed + List freshIds = restoredItems.stream() + .map(IItem::getId) + .collect(Collectors.toList()); + + if (coordinator != null) { + // Re-sync the coordinator to prevent a false "contextChanged" flag + coordinator.loadHistoricalContext(conv.getChatHashes(), freshIds, conv.getMessages()); + } + + // Restore the visual UI + AIContextManager.getInstance().addContextFiles(restoredItems); + } + } finally { + // Unlock the listener on the EDT + isSwitchingChats = false; } - - // Restore the visual UI - AIContextManager.getInstance().addContextFiles(restoredItems); }); } }).start(); From be78eede5ec171d07c9b4d2d165ebae52956e978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Fri, 15 May 2026 15:00:09 -0300 Subject: [PATCH 089/143] refactor: remove duplicate code --- .../app/ui/ai/context/AIContextManager.java | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java index c46f43214c..9b8396508c 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java @@ -7,6 +7,7 @@ import javax.swing.event.EventListenerList; import iped.app.ui.ai.model.ContextFileEntry; +import iped.app.ui.ai.util.AIWhatsappChatExtractor; import iped.app.ui.ai.util.SummaryValueExtractor; import iped.data.IItem; import iped.engine.lucene.analysis.CategoryTokenizer; @@ -44,6 +45,8 @@ public class AIContextManager { /** Listener list for context change events */ private final EventListenerList listeners; + + private AIWhatsappChatExtractor chatExtractor = new AIWhatsappChatExtractor(); /** * Private constructor to enforce singleton pattern. @@ -222,7 +225,7 @@ public void addContextFiles(List items) { } private String getRejectionReason(IItem item) { - if (!isWhatsAppChatItem(item)) { + if (!chatExtractor.isPotentiallyValidChat(item)) { return "Rejected: Not a WhatsApp chat item."; } @@ -241,26 +244,6 @@ private String getRejectionReason(IItem item) { return null; } - private boolean isWhatsAppChatItem(IItem item) { - if (item == null) { - return false; - } - - String chatContentType = WhatsAppParser.WHATSAPP_CHAT.toString(); - if (item.getMediaType() != null && chatContentType.equals(item.getMediaType().toString())) { - return true; - } - - if (item.getMetadata() != null) { - String indexedContentType = item.getMetadata().get(StandardParser.INDEXER_CONTENT_TYPE); - if (chatContentType.equals(indexedContentType)) { - return true; - } - } - - return false; - } - private boolean hasEmptyFilesCategory(IItem item) { if (item == null) { return false; From 5fbd776f216c0b7b5ca2e74352b9d3f795189781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Fri, 15 May 2026 17:19:02 -0300 Subject: [PATCH 090/143] refactor: Introduce ChatAreaPanel and HeaderPanel for improved UI structure and encapsulation --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 182 +++++++----------- .../iped/app/ui/ai/view/ChatAreaPanel.java | 115 +++++++++++ .../java/iped/app/ui/ai/view/HeaderPanel.java | 64 ++++++ 3 files changed, 244 insertions(+), 117 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/view/HeaderPanel.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 227ed8aa3c..f0f2d8f81e 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -16,7 +16,6 @@ import javax.swing.border.EmptyBorder; import javax.swing.border.TitledBorder; import javax.swing.text.BadLocationException; -import javax.swing.text.DefaultStyledDocument; import javax.swing.text.StyledDocument; import iped.app.ui.ai.AIChatCoordinator; @@ -63,13 +62,10 @@ public class AIAssistantPanel { // Main UI components private JFrame frame; // main window - private JTextPane chatArea; - private JScrollPane chatScrollPane; - private StyledDocument chatDocument; - private JTextArea inputArea; - private JButton sendButton; - private JLabel statusLabel; - private JProgressBar progressBar; + + private ChatAreaPanel chatAreaPanel; // Contains the chat display and input area + + private HeaderPanel headerPanel; private AIMarkdownRenderer markdownRenderer; private AIChatMessage draftMessage; @@ -212,8 +208,9 @@ private void createUI() { JPanel mainPanel = new JPanel(new BorderLayout(5, 5)); mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); - // Header - mainPanel.add(createHeaderPanel(), BorderLayout.NORTH); + // Instancia o painel passando o título e a ação de toggle via expressão lambda + headerPanel = new HeaderPanel(title, e -> toggleSidebar()); + mainPanel.add(headerPanel, BorderLayout.NORTH); // Chat Workspace JPanel chatWorkspacePanel = new JPanel(new BorderLayout(5, 5)); @@ -221,33 +218,41 @@ private void createUI() { JPanel centerPanel = new JPanel(new BorderLayout(5, 5)); centerPanel.add(createContextSection(), BorderLayout.NORTH); - // Chat display area setup - chatArea = new JTextPane(); - chatArea.setEditable(false); - chatArea.setBackground(new Color(0xf5, 0xf5, 0xf5)); - chatArea.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); - chatDocument = new DefaultStyledDocument(); - chatArea.setDocument(chatDocument); + String sendText = "Send"; + try { sendText = Messages.getString("AIAssistant.Send"); } catch (Exception e) {} + + chatAreaPanel = new ChatAreaPanel(PANEL_WIDTH, sendText); + + // Vinculação dos Listeners de Interação aos Componentes Encapsulados + chatAreaPanel.getSendButton().addActionListener(e -> handleSendAction()); + chatAreaPanel.getInputArea().addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER && !e.isShiftDown()) { + e.consume(); // Previne a quebra de linha padrão do JTextArea + handleSendAction(); + } + } + }); + + // acoplamento temporario try { - markdownRenderer = new AIMarkdownRenderer(chatArea); - chatDocument = markdownRenderer.getDocument(); + markdownRenderer = new AIMarkdownRenderer(chatAreaPanel.getChatArea()); + chatAreaPanel.setChatDocument(markdownRenderer.getDocument()); installTokenClickHandler(); } catch (Throwable t) { System.err.println("Failed to initialize markdown renderer: " + t.getMessage()); t.printStackTrace(); markdownRenderer = null; } - refreshChatArea(); - chatScrollPane = new JScrollPane(chatArea); - chatScrollPane.setPreferredSize(new Dimension(PANEL_WIDTH, 400)); - centerPanel.add(chatScrollPane, BorderLayout.CENTER); + centerPanel.add(chatAreaPanel, BorderLayout.CENTER); + refreshChatArea(); JPanel tasksPanel = createTasksPanel(); centerPanel.add(tasksPanel, BorderLayout.EAST); chatWorkspacePanel.add(centerPanel, BorderLayout.CENTER); - chatWorkspacePanel.add(createBottomPanel(), BorderLayout.SOUTH); // Conversations Sidebar sidebarPanel = createSidebarPanel(); @@ -271,6 +276,10 @@ private void createUI() { } private void installTokenClickHandler() { + + JTextPane chatArea = chatAreaPanel.getChatArea(); + StyledDocument doc = chatAreaPanel.getChatDocument(); + chatArea.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { @@ -279,11 +288,11 @@ public void mouseClicked(MouseEvent e) { } int offset = chatArea.viewToModel2D(e.getPoint()); - if (offset < 0 || chatDocument == null) { + if (offset < 0 || chatAreaPanel.getChatDocument() == null) { return; } - javax.swing.text.Element element = chatDocument.getCharacterElement(offset); + javax.swing.text.Element element = doc.getCharacterElement(offset); javax.swing.text.AttributeSet attributes = element.getAttributes(); Object tokenFlag = attributes.getAttribute(AIMarkdownRenderer.TOKEN_ATTRIBUTE); if (Boolean.TRUE.equals(tokenFlag)) { @@ -372,39 +381,6 @@ private void selectItemInResultsTable(int luceneId) { } } - private JPanel createHeaderPanel() { - JPanel headerPanel = new JPanel(new BorderLayout()); - - // Toggle Sidebar Button - JButton toggleSidebarBtn = new JButton("☰"); - toggleSidebarBtn.setMargin(new Insets(2, 6, 2, 6)); - toggleSidebarBtn.setFocusPainted(false); - toggleSidebarBtn.setToolTipText("Toggle Sidebar"); - toggleSidebarBtn.addActionListener(e -> toggleSidebar()); - - String titleText = "AI Assistant"; - try { titleText = Messages.getString("AIAssistant.Title"); } catch (Exception e) {} - JLabel titleLabel = new JLabel(titleText); - titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14)); - - statusLabel = new JLabel("● Connected to local backend server"); - statusLabel.setForeground(new Color(0, 150, 0)); // Green for active - - // Group toggle button and title - JPanel titleArea = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 0)); - titleArea.add(toggleSidebarBtn); - titleArea.add(titleLabel); - - JPanel leftPanel = new JPanel(new BorderLayout(0, 5)); - leftPanel.add(titleArea, BorderLayout.NORTH); - leftPanel.add(statusLabel, BorderLayout.SOUTH); - - headerPanel.add(leftPanel, BorderLayout.WEST); - headerPanel.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Color.LIGHT_GRAY)); - - return headerPanel; - } - private JPanel createSidebarPanel() { JPanel panel = new JPanel(new BorderLayout(0, 5)); panel.setMinimumSize(new Dimension(150, 0)); @@ -558,7 +534,7 @@ private void startNewChat() { refreshChatArea(); addMessage("System", "Started a new conversation session."); - inputArea.requestFocusInWindow(); + chatAreaPanel.getInputArea().requestFocusInWindow(); } /** @@ -855,7 +831,7 @@ private JPanel createTasksPanel() { btn.setMaximumSize(new Dimension(200, 30)); // Firing a pre-written prompt directly into the input area logic btn.addActionListener(e -> { - inputArea.setText(taskPrompts.get(task)); + chatAreaPanel.getInputArea().setText(taskPrompts.get(task)); handleSendAction(); }); panel.add(btn); @@ -876,54 +852,15 @@ private void clearChatScreenAndMemory() { if (coordinator != null) { coordinator.clearHistory(); } - - // Wipe the UI screen - try { - if (markdownRenderer != null) { - markdownRenderer.commitDraft(); // Resets anchor to -1 - } - chatDocument.remove(0, chatDocument.getLength()); - } catch (BadLocationException e) { - System.err.println("Error clearing chat document: " + e.getMessage()); + if (markdownRenderer != null) { + markdownRenderer.commitDraft(); // Resets anchor to -1 } - } - - private JPanel createBottomPanel() { - JPanel bottomPanel = new JPanel(new BorderLayout(5, 5)); - - progressBar = new JProgressBar(); - progressBar.setIndeterminate(true); - progressBar.setVisible(false); - - inputArea = new JTextArea(6, 20); - inputArea.setLineWrap(true); - inputArea.setBorder(BorderFactory.createLineBorder(Color.GRAY)); - - // Listen for "Enter" key to trigger send, allowing "Shift+Enter" for new lines - inputArea.addKeyListener(new KeyAdapter() { - @Override - public void keyPressed(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_ENTER && !e.isShiftDown()) { - e.consume(); // Prevent adding a newline character - handleSendAction(); - } - } - }); - - String sendText = "Send"; - try { sendText = Messages.getString("AIAssistant.Send"); } catch (Exception e) {} - - sendButton = new JButton(sendText); - sendButton.addActionListener(e -> handleSendAction()); - bottomPanel.add(progressBar, BorderLayout.NORTH); - bottomPanel.add(new JScrollPane(inputArea), BorderLayout.CENTER); - bottomPanel.add(sendButton, BorderLayout.EAST); - - return bottomPanel; - } + chatAreaPanel.clearChatScreen(); + } private void refreshChatArea() { + JScrollPane chatScrollPane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; JScrollBar verticalBar = chatScrollPane != null ? chatScrollPane.getVerticalScrollBar() : null; boolean shouldAutoFollow = shouldAutoFollow(verticalBar); int previousScrollValue = verticalBar != null ? verticalBar.getValue() : -1; @@ -935,14 +872,16 @@ private void refreshChatArea() { } SwingUtilities.invokeLater(() -> { - if (chatScrollPane == null) { + JScrollPane scrollPane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; + if (scrollPane == null) { return; } JScrollBar bar = chatScrollPane.getVerticalScrollBar(); if (shouldAutoFollow) { bar.setValue(bar.getMaximum()); - chatArea.setCaretPosition(chatArea.getDocument().getLength()); + JTextPane area = chatAreaPanel.getChatArea(); + area.setCaretPosition(area.getDocument().getLength()); return; } @@ -964,10 +903,15 @@ private boolean shouldAutoFollow(JScrollBar verticalBar) { private void renderMessagesFallback() { try { - chatDocument.remove(0, chatDocument.getLength()); + StyledDocument doc = chatAreaPanel.getChatDocument(); + if(doc == null) { + return; + } + doc.remove(0, doc.getLength()); + for (AIChatMessage message : buildRenderableMessages()) { - chatDocument.insertString( - chatDocument.getLength(), + doc.insertString( + doc.getLength(), "[" + message.getTime() + "] " + message.getSender() + "\n" + message.getContent() + "\n\n", null ); @@ -1082,7 +1026,7 @@ private void showDialogSafely() { } frame.toFront(); frame.requestFocus(); - inputArea.requestFocusInWindow(); + chatAreaPanel.getInputArea().requestFocusInWindow(); } private void beginStreaming(AIChatMessage message) { @@ -1167,7 +1111,7 @@ private void resetStreamingState() { * The main execution block linking user intent to the background Coordinator. */ private void handleSendAction() { - String text = inputArea.getText().trim(); + String text = chatAreaPanel.getInputArea().getText().trim(); if (!text.isEmpty()) { if (!ensureChatServiceInitialized()) { return; @@ -1181,7 +1125,7 @@ private void handleSendAction() { // Print user message immediately addMessage("You", text, "user"); - inputArea.setText(""); + chatAreaPanel.getInputArea().setText(""); // Push message to the manager ConversationManager.getInstance().addMessageToActive( @@ -1239,13 +1183,12 @@ private void handleSendAction() { */ private void setProcessing(boolean processing) { this.processing = processing; - progressBar.setVisible(processing); - sendButton.setEnabled(!processing); - inputArea.setEnabled(!processing); + chatAreaPanel.setProcessing(processing); frame.setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) : Cursor.getDefaultCursor()); } private void appendFinalizedMessage(AIChatMessage message) { + JScrollPane chatScrollPane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; if (chatScrollPane == null) { refreshChatArea(); return; @@ -1264,6 +1207,7 @@ private void renderDraftMessage() { return; } + JScrollPane chatScrollPane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; if (chatScrollPane == null || markdownRenderer == null) { refreshChatArea(); return; @@ -1278,18 +1222,22 @@ private void renderDraftMessage() { } private void restoreScrollAfterIncrementalUpdate(boolean shouldAutoFollow, int previousScrollValue) { + JScrollPane chatScrollPane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; if (chatScrollPane == null) { return; } SwingUtilities.invokeLater(() -> { - if (chatScrollPane == null) { + // Verifica novamente na EDT para evitar Race Conditions + JScrollPane pane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; + if (pane == null) { return; } JScrollBar bar = chatScrollPane.getVerticalScrollBar(); if (shouldAutoFollow) { bar.setValue(bar.getMaximum()); + JTextPane chatArea = chatAreaPanel.getChatArea(); chatArea.setCaretPosition(chatArea.getDocument().getLength()); return; } diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java new file mode 100644 index 0000000000..e39a0dea36 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java @@ -0,0 +1,115 @@ +package iped.app.ui.ai.view; + +import java.awt.Dimension; +import java.awt.Color; +import java.awt.BorderLayout; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.JTextPane; +import javax.swing.text.BadLocationException; +import javax.swing.text.DefaultStyledDocument; +import javax.swing.text.StyledDocument; + +public class ChatAreaPanel extends JPanel { + private final JTextPane chatArea; + private final JScrollPane chatScrollPane; + private StyledDocument chatDocument; + private final JTextArea inputArea; + private final JButton sendButton; + private final JLabel statusLabel; + private final JProgressBar progressBar; + + public ChatAreaPanel(int panelWidth, String sendText) { + setLayout(new BorderLayout(5, 5)); + + chatArea = new JTextPane(); + chatArea.setEditable(false); + chatArea.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + + chatDocument = new DefaultStyledDocument(); + chatArea.setDocument(chatDocument); + + chatScrollPane = new JScrollPane(chatArea); + chatScrollPane.setPreferredSize(new Dimension(panelWidth, 400)); + + inputArea = new JTextArea(6, 20); + inputArea.setLineWrap(true); + inputArea.setBorder(BorderFactory.createLineBorder(Color.GRAY)); + + sendButton = new JButton(sendText); + + statusLabel = new JLabel("● Connected to local backend server"); + statusLabel.setForeground(new Color(0, 150, 0)); //green + + progressBar = new JProgressBar(); + progressBar.setIndeterminate(true); + progressBar.setVisible(false); + + JPanel bottomContainer = new JPanel(new BorderLayout(5, 5)); + bottomContainer.add(progressBar, BorderLayout.NORTH); + bottomContainer.add(new JScrollPane(inputArea), BorderLayout.CENTER); + bottomContainer.add(sendButton, BorderLayout.EAST); + + add(chatScrollPane, BorderLayout.CENTER); + add(bottomContainer, BorderLayout.SOUTH); + + chatArea.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + } + + public JTextPane getChatArea() { + return chatArea; + } + + public JScrollPane getChatScrollPane() { + return chatScrollPane; + } + + public StyledDocument getChatDocument() { + return chatDocument; + } + + public void setChatDocument(StyledDocument chatDocument) { + this.chatDocument = chatDocument; + } + + public JTextArea getInputArea() { + return inputArea; + } + + public JButton getSendButton() { + return sendButton; + } + + public JLabel getStatusLabel() { + return statusLabel; + } + + public JProgressBar getProgressBar() { + return progressBar; + } + + public void clearChatScreen() { + try { + if (chatDocument != null) { + chatDocument.remove(0, chatDocument.getLength()); + } + } catch (javax.swing.text.BadLocationException e) { + System.err.println("Error clearing chat document: " + e.getMessage()); + } + } + + public void setProcessing(boolean processing) { + progressBar.setVisible(processing); + sendButton.setEnabled(!processing); + inputArea.setEnabled(!processing); + setCursor(processing ? java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.WAIT_CURSOR) + : java.awt.Cursor.getDefaultCursor()); + } + +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/HeaderPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/HeaderPanel.java new file mode 100644 index 0000000000..6f9595be74 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/view/HeaderPanel.java @@ -0,0 +1,64 @@ +package iped.app.ui.ai.view; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.Insets; +import java.awt.event.ActionListener; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; + +/** + * Painel modularizado para o cabeçalho da aplicação. + * Encapsula o título, botão de controle da sidebar e rótulo de status do backend. + */ +public class HeaderPanel extends JPanel { + + private final JLabel statusLabel; + + public HeaderPanel(String titleText, ActionListener toggleSidebarListener) { + // Inicializa o JPanel base com BorderLayout + super(new BorderLayout()); + + // Botão de alternância da barra lateral (Sidebar) + JButton toggleSidebarBtn = new JButton("☰"); + toggleSidebarBtn.setMargin(new Insets(2, 6, 2, 6)); + toggleSidebarBtn.setFocusPainted(false); + toggleSidebarBtn.setToolTipText("Toggle Sidebar"); + // Executa a ação injetada pela classe principal + toggleSidebarBtn.addActionListener(toggleSidebarListener); + + // Rótulo do título + JLabel titleLabel = new JLabel(titleText); + titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14)); + + // Área do título (Botão + Texto) + JPanel titleArea = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 0)); + titleArea.add(toggleSidebarBtn); + titleArea.add(titleLabel); + + // Inicialização do rótulo de status + statusLabel = new JLabel("● Connected to local backend server"); + statusLabel.setForeground(new Color(0, 150, 0)); // Verde para ativo + + // Agrupamento do título e status no lado esquerdo + JPanel leftPanel = new JPanel(new BorderLayout(0, 5)); + leftPanel.add(titleArea, BorderLayout.NORTH); + leftPanel.add(statusLabel, BorderLayout.SOUTH); + + // Adiciona os componentes ao painel principal (this) + add(leftPanel, BorderLayout.WEST); + setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Color.LIGHT_GRAY)); + } + + /** + * Permite que controladores externos atualizem o estado visual do status do backend. + */ + public void updateStatus(String text, Color color) { + statusLabel.setText(text); + statusLabel.setForeground(color); + } +} \ No newline at end of file From 0accbd1395e55113fa8fefc58bea5d7f601a3fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Fri, 15 May 2026 19:03:07 -0300 Subject: [PATCH 091/143] refactor: Update assistant panel to use showFrame method and introduce ChatStreamAnimator for improved message streaming --- .../src/main/java/iped/app/ui/MenuClass.java | 4 +- .../iped/app/ui/ai/view/AIAssistantPanel.java | 343 +++--------------- .../iped/app/ui/ai/view/ChatAreaPanel.java | 110 +++++- .../app/ui/ai/view/ChatStreamAnimator.java | 103 ++++++ 4 files changed, 260 insertions(+), 300 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/view/ChatStreamAnimator.java diff --git a/iped-app/src/main/java/iped/app/ui/MenuClass.java b/iped-app/src/main/java/iped/app/ui/MenuClass.java index dafad20ec5..b98fb8e997 100644 --- a/iped-app/src/main/java/iped/app/ui/MenuClass.java +++ b/iped-app/src/main/java/iped/app/ui/MenuClass.java @@ -445,7 +445,7 @@ private void openAIAssistantWithItems(List itemsToAdd) { assistantPanel.startNewConversationWithCurrentContext(itemsToAdd); } else if (!activeConversation.hasAssistantReply()) { AIContextManager.getInstance().addContextFiles(itemsToAdd); - assistantPanel.showPanel(); + assistantPanel.showFrame(); } else { int result = JOptionPane.showConfirmDialog( null, @@ -457,7 +457,7 @@ private void openAIAssistantWithItems(List itemsToAdd) { if (result == JOptionPane.YES_OPTION) { assistantPanel.startNewConversationWithCurrentContext(itemsToAdd); } else if (result == JOptionPane.NO_OPTION) { - assistantPanel.showPanel(); + assistantPanel.showFrame(); } } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index f0f2d8f81e..4481323105 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -8,14 +8,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.swing.*; import javax.swing.border.EmptyBorder; import javax.swing.border.TitledBorder; -import javax.swing.text.BadLocationException; import javax.swing.text.StyledDocument; import iped.app.ui.ai.AIChatCoordinator; @@ -54,11 +51,8 @@ public class AIAssistantPanel { private static final int VERTICAL_OFFSET = 120; private static final double HEIGHT_PERCENTAGE = 0.8; private static final int PANEL_WIDTH = 750; - private static final int STREAM_APPEND_DELAY_MS = 30; - private static final int AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 24; private static final int CONTEXT_VISIBLE_ITEMS = 5; private static final int CONTEXT_REMOVE_HOTZONE_PX = 28; - private static final Pattern STREAM_PART_PATTERN = Pattern.compile("\\S+|\\s+"); // Main UI components private JFrame frame; // main window @@ -67,12 +61,6 @@ public class AIAssistantPanel { private HeaderPanel headerPanel; - private AIMarkdownRenderer markdownRenderer; - private AIChatMessage draftMessage; - private final List streamQueue = new ArrayList<>(); - private Timer streamTimer; - private AIChatMessage streamingMessage; - private Runnable streamDrainAction; private boolean processing; // Sidebar components @@ -190,7 +178,7 @@ public void startNewConversationWithCurrentContext(List pendingItems) { refreshSidebarList(); refreshChatArea(); conversationList.setSelectedValue(newConversation, true); - showDialogSafely(); + showFrame(); } public boolean isProcessing() { @@ -235,16 +223,7 @@ public void keyPressed(KeyEvent e) { } }); - // acoplamento temporario - try { - markdownRenderer = new AIMarkdownRenderer(chatAreaPanel.getChatArea()); - chatAreaPanel.setChatDocument(markdownRenderer.getDocument()); - installTokenClickHandler(); - } catch (Throwable t) { - System.err.println("Failed to initialize markdown renderer: " + t.getMessage()); - t.printStackTrace(); - markdownRenderer = null; - } + installTokenClickHandler(); centerPanel.add(chatAreaPanel, BorderLayout.CENTER); refreshChatArea(); @@ -278,7 +257,6 @@ public void keyPressed(KeyEvent e) { private void installTokenClickHandler() { JTextPane chatArea = chatAreaPanel.getChatArea(); - StyledDocument doc = chatAreaPanel.getChatDocument(); chatArea.addMouseListener(new MouseAdapter() { @Override @@ -288,13 +266,16 @@ public void mouseClicked(MouseEvent e) { } int offset = chatArea.viewToModel2D(e.getPoint()); + + StyledDocument currentDoc = chatAreaPanel.getChatDocument(); if (offset < 0 || chatAreaPanel.getChatDocument() == null) { return; } - javax.swing.text.Element element = doc.getCharacterElement(offset); + javax.swing.text.Element element = currentDoc.getCharacterElement(offset); javax.swing.text.AttributeSet attributes = element.getAttributes(); Object tokenFlag = attributes.getAttribute(AIMarkdownRenderer.TOKEN_ATTRIBUTE); + if (Boolean.TRUE.equals(tokenFlag)) { int start = element.getStartOffset(); int end = element.getEndOffset(); @@ -307,6 +288,7 @@ public void mouseClicked(MouseEvent e) { return; } + AIMarkdownRenderer markdownRenderer = chatAreaPanel.getMarkdownRenderer(); if (markdownRenderer != null && markdownRenderer.toggleThinkingAtOffset(offset)) { refreshChatArea(); } @@ -638,9 +620,8 @@ else if (conv.getContextIds() != null && !conv.getContextIds().isEmpty()) { }).start(); // Reset UI streaming states - draftMessage = null; - if (markdownRenderer != null) { - markdownRenderer.commitDraft(); + if (chatAreaPanel != null) { + chatAreaPanel.forceDiscardStreaming(); } // Redraw the screen @@ -846,79 +827,19 @@ private JPanel createTasksPanel() { * Does NOT delete the saved messages in the ConversationManager. */ private void clearChatScreenAndMemory() { - draftMessage = null; - // Wipe the Coordinator's memory so the LLM forgets the previous context if (coordinator != null) { coordinator.clearHistory(); } - if (markdownRenderer != null) { - markdownRenderer.commitDraft(); // Resets anchor to -1 - } - - chatAreaPanel.clearChatScreen(); - } - - private void refreshChatArea() { - JScrollPane chatScrollPane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; - JScrollBar verticalBar = chatScrollPane != null ? chatScrollPane.getVerticalScrollBar() : null; - boolean shouldAutoFollow = shouldAutoFollow(verticalBar); - int previousScrollValue = verticalBar != null ? verticalBar.getValue() : -1; - - if (markdownRenderer != null) { - markdownRenderer.renderMessages(buildRenderableMessages()); - } else { - renderMessagesFallback(); - } - - SwingUtilities.invokeLater(() -> { - JScrollPane scrollPane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; - if (scrollPane == null) { - return; - } - - JScrollBar bar = chatScrollPane.getVerticalScrollBar(); - if (shouldAutoFollow) { - bar.setValue(bar.getMaximum()); - JTextPane area = chatAreaPanel.getChatArea(); - area.setCaretPosition(area.getDocument().getLength()); - return; - } - - if (previousScrollValue >= 0) { - int maxScroll = Math.max(0, bar.getMaximum() - bar.getVisibleAmount()); - bar.setValue(Math.min(previousScrollValue, maxScroll)); - } - }); - } - private boolean shouldAutoFollow(JScrollBar verticalBar) { - if (verticalBar == null) { - return true; + if (chatAreaPanel != null) { + chatAreaPanel.clearChatScreen(); } - - int distanceToBottom = verticalBar.getMaximum() - (verticalBar.getValue() + verticalBar.getVisibleAmount()); - return distanceToBottom <= AUTO_SCROLL_BOTTOM_THRESHOLD_PX; } - private void renderMessagesFallback() { - try { - StyledDocument doc = chatAreaPanel.getChatDocument(); - if(doc == null) { - return; - } - doc.remove(0, doc.getLength()); - - for (AIChatMessage message : buildRenderableMessages()) { - doc.insertString( - doc.getLength(), - "[" + message.getTime() + "] " + message.getSender() + "\n" + message.getContent() + "\n\n", - null - ); - } - } catch (BadLocationException e) { - System.err.println("Error rendering fallback chat: " + e.getMessage()); - e.printStackTrace(); + private void refreshChatArea() { + if (chatAreaPanel != null) { + chatAreaPanel.renderHistoricalMessages(buildRenderableMessages()); } } @@ -944,11 +865,7 @@ private void addMessage(String sender, String message, String type) { // Let the Manager hold the data ConversationManager.getInstance().addMessageToActive(chatMessage); - if (markdownRenderer != null) { - appendFinalizedMessage(chatMessage); - } else { - refreshChatArea(); - } + refreshChatArea(); } private void addMessage(String sender, String message) { @@ -965,8 +882,8 @@ private List buildRenderableMessages() { renderableMessages.addAll(activeConv.getMessages()); } - if (draftMessage != null) { - renderableMessages.add(draftMessage); + if (chatAreaPanel != null && chatAreaPanel.getCurrentDraftMessage() != null) { + renderableMessages.add(chatAreaPanel.getCurrentDraftMessage()); } return renderableMessages; } @@ -1016,95 +933,31 @@ private void ensureVisibleOnScreen() { } } - private void showDialogSafely() { - ensureVisibleOnScreen(); - if (frame.getExtendedState() == JFrame.ICONIFIED) { - frame.setExtendedState(JFrame.NORMAL); - } - if (!frame.isVisible()) { - frame.setVisible(true); - } - frame.toFront(); - frame.requestFocus(); - chatAreaPanel.getInputArea().requestFocusInWindow(); - } - - private void beginStreaming(AIChatMessage message) { - streamingMessage = message; - streamQueue.clear(); - streamDrainAction = null; - ensureStreamTimer(); - } - - private void ensureStreamTimer() { - if (streamTimer != null) { - return; - } - - streamTimer = new Timer(STREAM_APPEND_DELAY_MS, e -> onStreamTimerTick()); - } - - private void enqueueStreamToken(String token) { - if (streamingMessage == null || token == null || token.isEmpty()) { - return; - } - - Matcher matcher = STREAM_PART_PATTERN.matcher(token); - while (matcher.find()) { - streamQueue.add(matcher.group()); - } - - if (!streamQueue.isEmpty() && !streamTimer.isRunning()) { - streamTimer.start(); - } - } - - private void onStreamTimerTick() { - if (streamingMessage == null) { - streamTimer.stop(); - return; - } - - if (streamQueue.isEmpty()) { - streamTimer.stop(); - runPendingDrainAction(); - return; - } - - String part = streamQueue.remove(0); - streamingMessage.appendContent(part); - renderDraftMessage(); - } + public void showFrame() { + Runnable action = () -> { + ensureVisibleOnScreen(); + + if (frame.getExtendedState() == JFrame.ICONIFIED) { + frame.setExtendedState(JFrame.NORMAL); + } + + if (!frame.isVisible()) { + frame.setVisible(true); + } + + frame.toFront(); + frame.requestFocus(); + + if (chatAreaPanel != null) { + chatAreaPanel.requestFocusToInput(); + } + }; - private void completeStreaming(Runnable onDrained) { - if (streamQueue.isEmpty() && (streamTimer == null || !streamTimer.isRunning())) { - onDrained.run(); - resetStreamingState(); + if (SwingUtilities.isEventDispatchThread()) { + action.run(); } else { - streamDrainAction = () -> { - onDrained.run(); - resetStreamingState(); - }; - } - } - - private void runPendingDrainAction() { - if (streamDrainAction == null) { - return; - } - - Runnable action = streamDrainAction; - streamDrainAction = null; - action.run(); - } - - private void resetStreamingState() { - if (streamTimer != null) { - streamTimer.stop(); + SwingUtilities.invokeLater(action); } - streamQueue.clear(); - streamDrainAction = null; - streamingMessage = null; } /** @@ -1126,38 +979,26 @@ private void handleSendAction() { // Print user message immediately addMessage("You", text, "user"); chatAreaPanel.getInputArea().setText(""); - - // Push message to the manager - ConversationManager.getInstance().addMessageToActive( - AIChatMessage.now("You", text, "user") - ); // Lock the UI setProcessing(true); AIChatMessage assistantDraft = AIChatMessage.now("Assistant", "", "assistant"); - draftMessage = assistantDraft; - beginStreaming(assistantDraft); - renderDraftMessage(); + + chatAreaPanel.startMessageStreaming(assistantDraft); // Call the service coordinator.askQuestion( text, // Callback 1: Append tokens to the live assistant draft - (token) -> javax.swing.SwingUtilities.invokeLater(() -> { - enqueueStreamToken(token); + (token) -> SwingUtilities.invokeLater(() -> { + chatAreaPanel.enqueueStreamingToken(token); }), // Callback 2: Keep the completed draft visible and unlock the UI - () -> javax.swing.SwingUtilities.invokeLater(() -> { - completeStreaming(() -> { - if (assistantDraft.getContent().isEmpty()) { - if (markdownRenderer != null) markdownRenderer.discardDraft(); - draftMessage = null; - } else { - if (markdownRenderer != null) markdownRenderer.commitDraft(); - draftMessage = null; - - // Save the LLM's answer to active conversation state + () -> SwingUtilities.invokeLater(() -> { + chatAreaPanel.pruneStreaming(() -> { + if (!assistantDraft.getContent().isEmpty()) { + // Registra a resposta final estável no modelo de dados histórico do IPED ConversationManager.getInstance().addMessageToActive(assistantDraft); refreshSidebarList(); } @@ -1165,12 +1006,8 @@ private void handleSendAction() { }); }), // Callback 3: Handle Errors - (errorMessage) -> javax.swing.SwingUtilities.invokeLater(() -> { - if (markdownRenderer != null) { - markdownRenderer.discardDraft(); - } - resetStreamingState(); - draftMessage = null; + (errorMessage) -> SwingUtilities.invokeLater(() -> { + chatAreaPanel.forceDiscardStreaming(); addMessage("System Error", errorMessage, "error"); setProcessing(false); }) @@ -1187,90 +1024,14 @@ private void setProcessing(boolean processing) { frame.setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) : Cursor.getDefaultCursor()); } - private void appendFinalizedMessage(AIChatMessage message) { - JScrollPane chatScrollPane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; - if (chatScrollPane == null) { - refreshChatArea(); - return; - } - - JScrollBar verticalBar = chatScrollPane.getVerticalScrollBar(); - boolean shouldAutoFollow = shouldAutoFollow(verticalBar); - int previousScrollValue = verticalBar != null ? verticalBar.getValue() : -1; - - markdownRenderer.appendMessage(message); - restoreScrollAfterIncrementalUpdate(shouldAutoFollow, previousScrollValue); - } - - private void renderDraftMessage() { - if (draftMessage == null) { - return; - } - - JScrollPane chatScrollPane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; - if (chatScrollPane == null || markdownRenderer == null) { - refreshChatArea(); - return; - } - - JScrollBar verticalBar = chatScrollPane.getVerticalScrollBar(); - boolean shouldAutoFollow = shouldAutoFollow(verticalBar); - int previousScrollValue = verticalBar != null ? verticalBar.getValue() : -1; - - markdownRenderer.renderDraft(draftMessage); - restoreScrollAfterIncrementalUpdate(shouldAutoFollow, previousScrollValue); - } - - private void restoreScrollAfterIncrementalUpdate(boolean shouldAutoFollow, int previousScrollValue) { - JScrollPane chatScrollPane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; - if (chatScrollPane == null) { - return; - } - - SwingUtilities.invokeLater(() -> { - // Verifica novamente na EDT para evitar Race Conditions - JScrollPane pane = chatAreaPanel != null ? chatAreaPanel.getChatScrollPane() : null; - if (pane == null) { - return; - } - - JScrollBar bar = chatScrollPane.getVerticalScrollBar(); - if (shouldAutoFollow) { - bar.setValue(bar.getMaximum()); - JTextPane chatArea = chatAreaPanel.getChatArea(); - chatArea.setCaretPosition(chatArea.getDocument().getLength()); - return; - } - - if (previousScrollValue >= 0) { - int maxScroll = Math.max(0, bar.getMaximum() - bar.getVisibleAmount()); - bar.setValue(Math.min(previousScrollValue, maxScroll)); - } - }); - } public void toggleVisibility() { - Runnable action = () -> { + SwingUtilities.invokeLater(() -> { if (frame.isVisible()) { frame.setVisible(false); } else { - showDialogSafely(); + showFrame(); } - }; - - if (SwingUtilities.isEventDispatchThread()) { - action.run(); - } else { - SwingUtilities.invokeLater(action); - } - } - - public void showPanel() { - Runnable action = this::showDialogSafely; - if (SwingUtilities.isEventDispatchThread()) { - action.run(); - } else { - SwingUtilities.invokeLater(action); - } + }); } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java index e39a0dea36..303301b1c5 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java @@ -1,7 +1,9 @@ package iped.app.ui.ai.view; import java.awt.Dimension; +import java.util.List; import java.awt.Color; +import java.awt.Cursor; import java.awt.BorderLayout; import javax.swing.BorderFactory; @@ -9,13 +11,17 @@ import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JProgressBar; +import javax.swing.JScrollBar; import javax.swing.JScrollPane; import javax.swing.JTextArea; import javax.swing.JTextPane; +import javax.swing.SwingUtilities; import javax.swing.text.BadLocationException; import javax.swing.text.DefaultStyledDocument; import javax.swing.text.StyledDocument; +import iped.app.ui.ai.model.AIChatMessage; + public class ChatAreaPanel extends JPanel { private final JTextPane chatArea; private final JScrollPane chatScrollPane; @@ -25,6 +31,10 @@ public class ChatAreaPanel extends JPanel { private final JLabel statusLabel; private final JProgressBar progressBar; + private final AIMarkdownRenderer markdownRenderer; + private final ChatStreamAnimator streamAnimator; + private AIChatMessage currentDraftMessage; + public ChatAreaPanel(int panelWidth, String sendText) { setLayout(new BorderLayout(5, 5)); @@ -32,8 +42,10 @@ public ChatAreaPanel(int panelWidth, String sendText) { chatArea.setEditable(false); chatArea.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); - chatDocument = new DefaultStyledDocument(); - chatArea.setDocument(chatDocument); + this.markdownRenderer = new AIMarkdownRenderer(chatArea); + this.chatDocument = markdownRenderer.getDocument(); + + this.streamAnimator = new ChatStreamAnimator(() -> renderActiveDraft()); chatScrollPane = new JScrollPane(chatArea); chatScrollPane.setPreferredSize(new Dimension(panelWidth, 400)); @@ -58,8 +70,56 @@ public ChatAreaPanel(int panelWidth, String sendText) { add(chatScrollPane, BorderLayout.CENTER); add(bottomContainer, BorderLayout.SOUTH); - - chatArea.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + } + + public void startMessageStreaming(AIChatMessage assistantDraft) { + this.currentDraftMessage = assistantDraft; + streamAnimator.beginStreaming(assistantDraft); + renderActiveDraft(); + } + + public void enqueueStreamingToken(String token) { + streamAnimator.enqueueToken(token); + } + + public void pruneStreaming(Runnable onDrained) { + streamAnimator.completeStreaming(() -> { + if (currentDraftMessage != null) { + if (currentDraftMessage.getContent().isEmpty()) { + markdownRenderer.discardDraft(); + } else { + markdownRenderer.commitDraft(); + } + } + currentDraftMessage = null; + onDrained.run(); + }); + } + + public void forceDiscardStreaming() { + streamAnimator.resetState(); + markdownRenderer.discardDraft(); + currentDraftMessage = null; + } + + private void renderActiveDraft() { + if (currentDraftMessage != null) { + markdownRenderer.renderDraft(currentDraftMessage); + adjustScrollToBottom(); + } + } + + public void renderHistoricalMessages(List messages) { + markdownRenderer.renderMessages(messages); + adjustScrollToBottom(); + } + + private void adjustScrollToBottom() { + SwingUtilities.invokeLater(() -> { + JScrollBar bar = chatScrollPane.getVerticalScrollBar(); + bar.setValue(bar.getMaximum()); + chatArea.setCaretPosition(markdownRenderer.getDocument().getLength()); + }); } public JTextPane getChatArea() { @@ -94,22 +154,58 @@ public JProgressBar getProgressBar() { return progressBar; } + public AIMarkdownRenderer getMarkdownRenderer() { + return markdownRenderer; + } + + public ChatStreamAnimator getStreamAnimator() { + return streamAnimator; + } + + public AIChatMessage getCurrentDraftMessage() { + return currentDraftMessage; + } + + public void setCurrentDraftMessage(AIChatMessage currentDraftMessage) { + this.currentDraftMessage = currentDraftMessage; + } + public void clearChatScreen() { + + if (streamAnimator != null) { + streamAnimator.resetState(); + } + + if (markdownRenderer != null) { + markdownRenderer.commitDraft(); + } + try { if (chatDocument != null) { chatDocument.remove(0, chatDocument.getLength()); } - } catch (javax.swing.text.BadLocationException e) { + } catch (BadLocationException e) { System.err.println("Error clearing chat document: " + e.getMessage()); } + + currentDraftMessage = null; } public void setProcessing(boolean processing) { progressBar.setVisible(processing); sendButton.setEnabled(!processing); inputArea.setEnabled(!processing); - setCursor(processing ? java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.WAIT_CURSOR) - : java.awt.Cursor.getDefaultCursor()); + setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) + : Cursor.getDefaultCursor()); + } + + /** + * Transfere o foco do teclado diretamente para a área de input de texto. + */ + public void requestFocusToInput() { + if (inputArea != null) { + inputArea.requestFocusInWindow(); + } } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ChatStreamAnimator.java b/iped-app/src/main/java/iped/app/ui/ai/view/ChatStreamAnimator.java new file mode 100644 index 0000000000..e318d85a24 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ChatStreamAnimator.java @@ -0,0 +1,103 @@ +package iped.app.ui.ai.view; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.swing.Timer; +import iped.app.ui.ai.model.AIChatMessage; + +/** + * Classe especialista responsável por gerenciar a fila e a animação (efeito teletipo) + * dos tokens de texto recebidos da LLM em segundo plano. + */ +public class ChatStreamAnimator { + + private static final int STREAM_APPEND_DELAY_MS = 30; + private static final Pattern STREAM_PART_PATTERN = Pattern.compile("\\S+|\\s+"); + + private final Timer streamTimer; + private final List streamQueue = new ArrayList<>(); + private final Runnable onTickUpdateAction; + + private AIChatMessage streamingMessage; + private Runnable streamDrainAction; + + /** + * @param onTickUpdateAction Callback executado a cada palavra adicionada para atualizar a UI. + */ + public ChatStreamAnimator(Runnable onTickUpdateAction) { + this.onTickUpdateAction = onTickUpdateAction; + this.streamTimer = new Timer(STREAM_APPEND_DELAY_MS, e -> onTimerTick()); + } + + public void beginStreaming(AIChatMessage message) { + this.streamingMessage = message; + this.streamQueue.clear(); + this.streamDrainAction = null; + } + + public void enqueueToken(String token) { + if (streamingMessage == null || token == null || token.isEmpty()) { + return; + } + + Matcher matcher = STREAM_PART_PATTERN.matcher(token); + while (matcher.find()) { + streamQueue.add(matcher.group()); + } + + if (!streamQueue.isEmpty() && !streamTimer.isRunning()) { + streamTimer.start(); + } + } + + private void onTimerTick() { + if (streamingMessage == null) { + streamTimer.stop(); + return; + } + + if (streamQueue.isEmpty()) { + streamTimer.stop(); + runPendingDrainAction(); + return; + } + + String part = streamQueue.remove(0); + streamingMessage.appendContent(part); + + // Notifica o painel que o conteúdo mudou e a tela precisa ser atualizada + onTickUpdateAction.run(); + } + + public void completeStreaming(Runnable onDrained) { + if (streamQueue.isEmpty() && !streamTimer.isRunning()) { + onDrained.run(); + resetState(); + } else { + streamDrainAction = () -> { + onDrained.run(); + resetState(); + }; + } + } + + private void runPendingDrainAction() { + if (streamDrainAction == null) { + return; + } + Runnable action = streamDrainAction; + streamDrainAction = null; + action.run(); + } + + public void resetState() { + if (streamTimer.isRunning()) { + streamTimer.stop(); + } + streamQueue.clear(); + streamDrainAction = null; + streamingMessage = null; + } +} \ No newline at end of file From bd79b23e0bcebf321f3b0208fe82409f2a0a1a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Fri, 15 May 2026 19:27:00 -0300 Subject: [PATCH 092/143] refactor: move token navigation and UI interaction logic to ChatAreaPanel --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 51 +++--------------- .../iped/app/ui/ai/view/ChatAreaPanel.java | 54 ++++++++++++++++++- 2 files changed, 59 insertions(+), 46 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 4481323105..df1d31383d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -55,7 +55,7 @@ public class AIAssistantPanel { private static final int CONTEXT_REMOVE_HOTZONE_PX = 28; // Main UI components - private JFrame frame; // main window + private JFrame frame; private ChatAreaPanel chatAreaPanel; // Contains the chat display and input area @@ -223,11 +223,14 @@ public void keyPressed(KeyEvent e) { } }); - installTokenClickHandler(); - centerPanel.add(chatAreaPanel, BorderLayout.CENTER); refreshChatArea(); + chatAreaPanel.installTextPaneClickListener( + (hash, chunkId) -> navigateToItem(hash, chunkId), // Regra 1: Navega no IPED + this::refreshChatArea // Regra 2: Atualiza o estado visual + ); + JPanel tasksPanel = createTasksPanel(); centerPanel.add(tasksPanel, BorderLayout.EAST); @@ -254,48 +257,6 @@ public void keyPressed(KeyEvent e) { refreshSidebarList(); } - private void installTokenClickHandler() { - - JTextPane chatArea = chatAreaPanel.getChatArea(); - - chatArea.addMouseListener(new MouseAdapter() { - @Override - public void mouseClicked(MouseEvent e) { - if (e.getButton() != MouseEvent.BUTTON1) { - return; - } - - int offset = chatArea.viewToModel2D(e.getPoint()); - - StyledDocument currentDoc = chatAreaPanel.getChatDocument(); - if (offset < 0 || chatAreaPanel.getChatDocument() == null) { - return; - } - - javax.swing.text.Element element = currentDoc.getCharacterElement(offset); - javax.swing.text.AttributeSet attributes = element.getAttributes(); - Object tokenFlag = attributes.getAttribute(AIMarkdownRenderer.TOKEN_ATTRIBUTE); - - if (Boolean.TRUE.equals(tokenFlag)) { - int start = element.getStartOffset(); - int end = element.getEndOffset(); - chatArea.setSelectionStart(start); - chatArea.setSelectionEnd(Math.max(start, end)); - - Object hash = attributes.getAttribute(AIMarkdownRenderer.TOKEN_HASH_ATTRIBUTE); - Object chunkId = attributes.getAttribute(AIMarkdownRenderer.TOKEN_CHUNK_ID_ATTRIBUTE); - navigateToItem(String.valueOf(hash), String.valueOf(chunkId)); - return; - } - - AIMarkdownRenderer markdownRenderer = chatAreaPanel.getMarkdownRenderer(); - if (markdownRenderer != null && markdownRenderer.toggleThinkingAtOffset(offset)) { - refreshChatArea(); - } - } - }); - } - private void navigateToItem(String hash, String chunkId) { if (hash == null || hash.isEmpty() || App.get() == null || App.get().appCase == null) { return; diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java index 303301b1c5..310f21262f 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java @@ -2,6 +2,7 @@ import java.awt.Dimension; import java.util.List; +import java.util.function.BiConsumer; import java.awt.Color; import java.awt.Cursor; import java.awt.BorderLayout; @@ -200,12 +201,63 @@ public void setProcessing(boolean processing) { } /** - * Transfere o foco do teclado diretamente para a área de input de texto. + * Requests focus for the input area */ public void requestFocusToInput() { if (inputArea != null) { inputArea.requestFocusInWindow(); } } + + public void installTextPaneClickListener(BiConsumer navigationCallback, Runnable refreshCallback){ + if (chatArea == null) { + return; + } + chatArea.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + if (e.getButton() != java.awt.event.MouseEvent.BUTTON1) { + return; + } + + // converts the clicked point to a document offset + int offset = chatArea.viewToModel2D(e.getPoint()); + StyledDocument currentDoc = getChatDocument(); + if (offset < 0 || currentDoc == null) { + return; + } + + + javax.swing.text.Element element = chatDocument.getCharacterElement(offset); + javax.swing.text.AttributeSet attributes = element.getAttributes(); + Object tokenFlag = attributes.getAttribute(AIMarkdownRenderer.TOKEN_ATTRIBUTE); + + // 1. clicks on tokens with navigation metadata (e.g., hash and chunkId) + if (Boolean.TRUE.equals(tokenFlag)) { + int start = element.getStartOffset(); + int end = element.getEndOffset(); + chatArea.setSelectionStart(start); + chatArea.setSelectionEnd(Math.max(start, end)); + + Object hash = attributes.getAttribute(AIMarkdownRenderer.TOKEN_HASH_ATTRIBUTE); + Object chunkId = attributes.getAttribute(AIMarkdownRenderer.TOKEN_CHUNK_ID_ATTRIBUTE); + + // Invoke the navigation callback with the extracted metadata + if (navigationCallback != null) { + navigationCallback.accept(String.valueOf(hash), String.valueOf(chunkId)); + } + return; + } + + // 2. Cenário: Clique para expandir/recolher bloco de raciocínio da IA + if (markdownRenderer != null && markdownRenderer.toggleThinkingAtOffset(offset)) { + if (refreshCallback != null) { + refreshCallback.run(); + } + } + } + }); + + } } From 4c3d775681a861a40130d1e00aa7071c58ad868b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Mon, 18 May 2026 14:20:45 -0300 Subject: [PATCH 093/143] refactor: replace sidebar implementation with SidebarPanel for improved modularity and maintainability --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 161 +++---------- .../iped/app/ui/ai/view/SidebarPanel.java | 213 ++++++++++++++++++ 2 files changed, 244 insertions(+), 130 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index df1d31383d..77135ebc10 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -65,10 +65,7 @@ public class AIAssistantPanel { // Sidebar components private JSplitPane splitPane; - private JPanel sidebarPanel; - private JButton newChatButton; - private JList conversationList; - private DefaultListModel conversationListModel; + private SidebarPanel sidebarPanel; // Service layer that handles business logic and threading private AIChatCoordinator coordinator; @@ -177,7 +174,7 @@ public void startNewConversationWithCurrentContext(List pendingItems) { refreshSidebarList(); refreshChatArea(); - conversationList.setSelectedValue(newConversation, true); + sidebarPanel.getConversationList().setSelectedValue(newConversation, true); showFrame(); } @@ -237,7 +234,32 @@ public void keyPressed(KeyEvent e) { chatWorkspacePanel.add(centerPanel, BorderLayout.CENTER); // Conversations Sidebar - sidebarPanel = createSidebarPanel(); + sidebarPanel = new SidebarPanel(frame, new SidebarPanel.SidebarListener() { + @Override + public void onConversationSelected(Conversation conversation) { + loadConversation(conversation); + } + + @Override + public void onNewChatRequested() { + startNewChat(); + } + + @Override + public void onConversationDeleted(Conversation conversation, boolean isActiveDeleted) { + // Se a conversa que o usuário estava visualizando foi a deletada, limpa a tela ou carrega a próxima + if (isActiveDeleted) { + Conversation active = ConversationManager.getInstance().getActiveConversation(); + if (active != null) { + loadConversation(active); + } else { + clearChatScreenAndMemory(); + AIContextManager.getInstance().clearContext(); + refreshChatArea(); + } + } + } + }, ConversationManager.getInstance()); // The SplitPane connecting them splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, sidebarPanel, chatWorkspacePanel); @@ -324,125 +346,6 @@ private void selectItemInResultsTable(int luceneId) { } } - private JPanel createSidebarPanel() { - JPanel panel = new JPanel(new BorderLayout(0, 5)); - panel.setMinimumSize(new Dimension(150, 0)); - panel.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 5)); - - newChatButton = new JButton("+ New Chat"); - newChatButton.setFont(newChatButton.getFont().deriveFont(Font.BOLD)); - newChatButton.addActionListener(e -> startNewChat()); - - panel.add(newChatButton, BorderLayout.NORTH); - - // Conversation List UI - conversationListModel = new DefaultListModel<>(); - conversationList = new JList<>(conversationListModel); - conversationList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - - // Custom Renderer to make the items look like modern chat tabs - conversationList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { - JPanel rowPanel = new JPanel(new BorderLayout(8, 0)); - rowPanel.setBorder(BorderFactory.createEmptyBorder(8, 10, 8, 10)); // Padded - rowPanel.setOpaque(true); - rowPanel.setBackground(isSelected ? list.getSelectionBackground() : list.getBackground()); - - if (value instanceof Conversation) { - Conversation conv = (Conversation) value; - - JLabel textLabel = new JLabel(conv.getTitle()); - textLabel.setFont(list.getFont()); - textLabel.setForeground(isSelected ? list.getSelectionForeground() : list.getForeground()); - - JLabel removeLabel = new JLabel("X"); - removeLabel.setFont(list.getFont().deriveFont(Font.BOLD)); - // Only show red 'X' if selected, otherwise subtle gray, to keep UI clean - removeLabel.setForeground(isSelected ? new Color(160, 0, 0) : Color.LIGHT_GRAY); - - rowPanel.add(textLabel, BorderLayout.CENTER); - rowPanel.add(removeLabel, BorderLayout.EAST); - } - return rowPanel; - }); - - // Wire up the click listener for the sidebar tabs - conversationList.addMouseListener(new MouseAdapter() { - @Override - public void mouseClicked(MouseEvent e) { - if (e.getButton() != MouseEvent.BUTTON1) return; - - int index = conversationList.locationToIndex(e.getPoint()); - if (index < 0) return; - - Rectangle cellBounds = conversationList.getCellBounds(index, index); - if (cellBounds == null || !cellBounds.contains(e.getPoint())) return; - - Conversation selected = conversationListModel.getElementAt(index); - - // Check if clicked the 'X' hotzone - if (e.getX() >= cellBounds.x + cellBounds.width - 28) { - promptDeleteConversation(selected); - return; - } - - // Otherwise, load the conversation - Conversation active = ConversationManager.getInstance().getActiveConversation(); - - // Only load if they clicked a different conversation - if (selected != null && (active == null || !active.getId().equals(selected.getId()))) { - loadConversation(selected); - } - } - }); - - // Hide the scrollpane borders to blend seamlessly into the sidebar - JScrollPane scrollPane = new JScrollPane(conversationList); - scrollPane.setBorder(BorderFactory.createEmptyBorder()); - - panel.add(scrollPane, BorderLayout.CENTER); - - return panel; - } - - /** - * Confirmation to delete chat when clicking the 'X' on the Conversation list entry - */ - private void promptDeleteConversation(Conversation conv) { - int confirm = JOptionPane.showConfirmDialog(frame, - "Are you sure you want to delete this chat?\n\"" + conv.getTitle() + "\"", - "Delete Chat", - JOptionPane.YES_NO_OPTION, - JOptionPane.WARNING_MESSAGE); - - if (confirm == JOptionPane.YES_OPTION) { - // Delete the actual JSON file from the hard drive - ConversationPersistence.deleteConversation(conv.getId()); - - // Remove from memory - ConversationManager.getInstance().removeConversation(conv); - - // Check if the chat currently being looked at was deleted - Conversation active = ConversationManager.getInstance().getActiveConversation(); - if (active == null || active.getId().equals(conv.getId())) { - List remaining = ConversationManager.getInstance().getConversations(); - - if (!remaining.isEmpty()) { - // Option A: Load the conversation at the top of the list - loadConversation(remaining.get(0)); - refreshSidebarList(); - } else { - // Option B: The list is completely empty - ConversationManager.getInstance().setActiveConversation(null); - clearChatScreenAndMemory(); - AIContextManager.getInstance().clearContext(); - refreshSidebarList(); - } - } else { - refreshSidebarList(); // Just removes it visually from the sidebar - } - } - } - private void toggleSidebar() { if (sidebarPanel.isVisible()) { sidebarPanel.setVisible(false); @@ -456,11 +359,9 @@ private void toggleSidebar() { } private void refreshSidebarList() { - conversationListModel.clear(); - for (Conversation conv : ConversationManager.getInstance().getConversations()) { - conversationListModel.addElement(conv); + if (sidebarPanel != null) { + sidebarPanel.refreshList(); } - conversationList.repaint(); } private void startNewChat() { @@ -587,7 +488,7 @@ else if (conv.getContextIds() != null && !conv.getContextIds().isEmpty()) { // Redraw the screen refreshChatArea(); - conversationList.setSelectedValue(conv, true); + sidebarPanel.getConversationList().setSelectedValue(conv, true); } private JPanel createContextSection() { diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java new file mode 100644 index 0000000000..02d2c2d31a --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java @@ -0,0 +1,213 @@ +package iped.app.ui.ai.view; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Rectangle; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.List; + +import javax.swing.BorderFactory; +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ListSelectionModel; + +import iped.app.ui.ai.model.Conversation; +import iped.app.ui.ai.context.ConversationManager; +import iped.app.ui.ai.util.ConversationPersistence; + +/** + * Componente de interface gráfica responsável pela barra lateral de conversas. + * Aplica os princípios SRP (Responsabilidade Única) e DIP (Inversão de Dependência). + */ +public class SidebarPanel extends JPanel { + + + + private JButton newChatButton; + private JList conversationList; + private DefaultListModel conversationListModel; + + private final Component parentFrame; + private final SidebarListener listener; + + private final ConversationManager conversationManager; + + /** + * Interface de contrato para comunicação com o painel principal (AIAssistantPanel). + */ + public interface SidebarListener { + void onConversationSelected(Conversation conversation); + void onNewChatRequested(); + void onConversationDeleted(Conversation conversation, boolean isActiveDeleted); + } + + /** + * Construtor da Sidebar com injeção de dependências. + * + * @param parentFrame Componente pai necessário para posicionamento do JOptionPane. + * @param listener Implementação do contrato de eventos da barra lateral. + */ + public SidebarPanel(Component parentFrame, SidebarListener listener, ConversationManager conversationManager) { + this.parentFrame = parentFrame; + this.conversationManager = conversationManager; // dependency injection via constructor + this.listener = listener; + + configurePanelLayout(); + initComponents(); + } + + private void configurePanelLayout() { + setMinimumSize(new Dimension(150, 0)); + setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 5)); + setLayout(new BorderLayout(0, 5)); + } + + private void initComponents() { + // Inicialização do botão de criação de chat + newChatButton = new JButton("+ New Chat"); + newChatButton.setFont(newChatButton.getFont().deriveFont(Font.BOLD)); + newChatButton.addActionListener(e -> { + if (listener != null) { + listener.onNewChatRequested(); + } + }); + add(newChatButton, BorderLayout.NORTH); + + // Inicialização da lista de componentes + conversationListModel = new DefaultListModel<>(); + conversationList = new JList<>(conversationListModel); + conversationList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + + setupCellRenderer(); + setupMouseListeners(); + + JScrollPane scrollPane = new JScrollPane(conversationList); + scrollPane.setBorder(BorderFactory.createEmptyBorder()); + add(scrollPane, BorderLayout.CENTER); + } + + private void setupCellRenderer() { + conversationList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { + JPanel rowPanel = new JPanel(new BorderLayout(8, 0)); + rowPanel.setBorder(BorderFactory.createEmptyBorder(8, 10, 8, 10)); + rowPanel.setOpaque(true); + rowPanel.setBackground(isSelected ? list.getSelectionBackground() : list.getBackground()); + + if (value instanceof Conversation) { + Conversation conv = (Conversation) value; + + JLabel textLabel = new JLabel(conv.getTitle()); + textLabel.setFont(list.getFont()); + textLabel.setForeground(isSelected ? list.getSelectionForeground() : list.getForeground()); + + JLabel removeLabel = new JLabel("X"); + removeLabel.setFont(list.getFont().deriveFont(Font.BOLD)); + removeLabel.setForeground(isSelected ? new Color(160, 0, 0) : Color.LIGHT_GRAY); + + rowPanel.add(textLabel, BorderLayout.CENTER); + rowPanel.add(removeLabel, BorderLayout.EAST); + } + return rowPanel; + }); + } + + private void setupMouseListeners() { + conversationList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() != MouseEvent.BUTTON1) return; + + int index = conversationList.locationToIndex(e.getPoint()); + if (index < 0) return; + + Rectangle cellBounds = conversationList.getCellBounds(index, index); + if (cellBounds == null || !cellBounds.contains(e.getPoint())) return; + + Conversation selected = conversationListModel.getElementAt(index); + + // Verificação de clique na Hotzone do botão 'X' (28 pixels à direita) + if (e.getX() >= cellBounds.x + cellBounds.width - 28) { + promptDeleteConversation(selected); + return; + } + + // Seleção de nova conversa + Conversation active = conversationManager.getActiveConversation(); + if (selected != null && (active == null || !active.getId().equals(selected.getId()))) { + if (listener != null) { + listener.onConversationSelected(selected); + } + } + } + }); + } + + public JList getConversationList() { + return conversationList; + } + + public void setConversationList(JList conversationList) { + this.conversationList = conversationList; + } + + /** + * Sincroniza os dados locais do modelo visual com o ConversationManager. + */ + public void refreshList() { + conversationListModel.clear(); + for (Conversation conv : conversationManager.getConversations()) { + conversationListModel.addElement(conv); + } + conversationList.repaint(); + } + + /** + * Permite que a classe externa controle a seleção visual do componente. + */ + public void setSelectedValue(Conversation conv, boolean shouldScroll) { + conversationList.setSelectedValue(conv, shouldScroll); + } + + private void promptDeleteConversation(Conversation conv) { + int confirm = JOptionPane.showConfirmDialog(parentFrame, + "Are you sure you want to delete this chat?\n\"" + conv.getTitle() + "\"", + "Delete Chat", + JOptionPane.YES_NO_OPTION, + JOptionPane.WARNING_MESSAGE); + + if (confirm == JOptionPane.YES_OPTION) { + // Remove logicamente e fisicamente + ConversationPersistence.deleteConversation(conv.getId()); + conversationManager.removeConversation(conv); + + Conversation active = conversationManager.getActiveConversation(); + boolean isActiveDeleted = (active == null || active.getId().equals(conv.getId())); + + // Reposiciona o ponteiro de conversas caso a conversa ativa tenha sido deletada + if (isActiveDeleted) { + List remaining = ConversationManager.getInstance().getConversations(); + if (!remaining.isEmpty()) { + conversationManager.setActiveConversation(remaining.get(0)); + } else { + conversationManager.setActiveConversation(null); + } + } + + // Notifica o listener externo para atualizações colaterais (Ex: limpar tela) + if (listener != null) { + listener.onConversationDeleted(conv, isActiveDeleted); + } + + refreshList(); + } + } +} \ No newline at end of file From 06d67fa2eb9fb89fb169db98848169d4355f7eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Mon, 18 May 2026 14:51:26 -0300 Subject: [PATCH 094/143] refactor: remove comments in portuguese --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 10 ++++---- .../iped/app/ui/ai/view/ChatAreaPanel.java | 3 +-- .../iped/app/ui/ai/view/SidebarPanel.java | 23 +++++++------------ 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 77135ebc10..d5d366ca6e 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -193,7 +193,7 @@ private void createUI() { JPanel mainPanel = new JPanel(new BorderLayout(5, 5)); mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); - // Instancia o painel passando o título e a ação de toggle via expressão lambda + // Header with title and toggle sidebar button headerPanel = new HeaderPanel(title, e -> toggleSidebar()); mainPanel.add(headerPanel, BorderLayout.NORTH); @@ -208,13 +208,12 @@ private void createUI() { chatAreaPanel = new ChatAreaPanel(PANEL_WIDTH, sendText); - // Vinculação dos Listeners de Interação aos Componentes Encapsulados chatAreaPanel.getSendButton().addActionListener(e -> handleSendAction()); chatAreaPanel.getInputArea().addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_ENTER && !e.isShiftDown()) { - e.consume(); // Previne a quebra de linha padrão do JTextArea + e.consume(); handleSendAction(); } } @@ -224,8 +223,8 @@ public void keyPressed(KeyEvent e) { refreshChatArea(); chatAreaPanel.installTextPaneClickListener( - (hash, chunkId) -> navigateToItem(hash, chunkId), // Regra 1: Navega no IPED - this::refreshChatArea // Regra 2: Atualiza o estado visual + (hash, chunkId) -> navigateToItem(hash, chunkId), + this::refreshChatArea ); JPanel tasksPanel = createTasksPanel(); @@ -247,7 +246,6 @@ public void onNewChatRequested() { @Override public void onConversationDeleted(Conversation conversation, boolean isActiveDeleted) { - // Se a conversa que o usuário estava visualizando foi a deletada, limpa a tela ou carrega a próxima if (isActiveDeleted) { Conversation active = ConversationManager.getInstance().getActiveConversation(); if (active != null) { diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java index 310f21262f..6b4d906d0b 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java @@ -18,7 +18,6 @@ import javax.swing.JTextPane; import javax.swing.SwingUtilities; import javax.swing.text.BadLocationException; -import javax.swing.text.DefaultStyledDocument; import javax.swing.text.StyledDocument; import iped.app.ui.ai.model.AIChatMessage; @@ -249,7 +248,7 @@ public void mouseClicked(java.awt.event.MouseEvent e) { return; } - // 2. Cenário: Clique para expandir/recolher bloco de raciocínio da IA + // 2. clicks on other tokens without navigation metadata will toggle "thinking" state for that token if (markdownRenderer != null && markdownRenderer.toggleThinkingAtOffset(offset)) { if (refreshCallback != null) { refreshCallback.run(); diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java index 02d2c2d31a..3f7a379c0e 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java @@ -25,13 +25,10 @@ import iped.app.ui.ai.util.ConversationPersistence; /** - * Componente de interface gráfica responsável pela barra lateral de conversas. - * Aplica os princípios SRP (Responsabilidade Única) e DIP (Inversão de Dependência). + * Sidebar component posponsable for displaying the list of conversations and allowing users to create, select, or delete conversations. */ public class SidebarPanel extends JPanel { - - private JButton newChatButton; private JList conversationList; private DefaultListModel conversationListModel; @@ -42,7 +39,7 @@ public class SidebarPanel extends JPanel { private final ConversationManager conversationManager; /** - * Interface de contrato para comunicação com o painel principal (AIAssistantPanel). + * Contract for the SidebarPanel's event listener, allowing external components to react to user interactions */ public interface SidebarListener { void onConversationSelected(Conversation conversation); @@ -51,10 +48,10 @@ public interface SidebarListener { } /** - * Construtor da Sidebar com injeção de dependências. + * Constructs the SidebarPanel with necessary dependencies and initializes the UI components. * - * @param parentFrame Componente pai necessário para posicionamento do JOptionPane. - * @param listener Implementação do contrato de eventos da barra lateral. + * @param parentFrame Parent component used for dialog positioning. + * @param listener External listener to handle sidebar events (selection, creation, deletion). */ public SidebarPanel(Component parentFrame, SidebarListener listener, ConversationManager conversationManager) { this.parentFrame = parentFrame; @@ -72,7 +69,7 @@ private void configurePanelLayout() { } private void initComponents() { - // Inicialização do botão de criação de chat + // Initializes the "New Chat" button with styling and action listener newChatButton = new JButton("+ New Chat"); newChatButton.setFont(newChatButton.getFont().deriveFont(Font.BOLD)); newChatButton.addActionListener(e -> { @@ -82,7 +79,7 @@ private void initComponents() { }); add(newChatButton, BorderLayout.NORTH); - // Inicialização da lista de componentes + // Initializes the conversation list with a custom cell renderer and mouse listener for selection and deletion conversationListModel = new DefaultListModel<>(); conversationList = new JList<>(conversationListModel); conversationList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); @@ -140,7 +137,6 @@ public void mouseClicked(MouseEvent e) { return; } - // Seleção de nova conversa Conversation active = conversationManager.getActiveConversation(); if (selected != null && (active == null || !active.getId().equals(selected.getId()))) { if (listener != null) { @@ -160,7 +156,7 @@ public void setConversationList(JList conversationList) { } /** - * Sincroniza os dados locais do modelo visual com o ConversationManager. + * Sincronized method to refresh the conversation list UI based on the current state of ConversationManager. */ public void refreshList() { conversationListModel.clear(); @@ -170,9 +166,6 @@ public void refreshList() { conversationList.repaint(); } - /** - * Permite que a classe externa controle a seleção visual do componente. - */ public void setSelectedValue(Conversation conv, boolean shouldScroll) { conversationList.setSelectedValue(conv, shouldScroll); } From 3c80abd1142507e25dcd2556c1387a65d39109e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Mon, 18 May 2026 16:13:16 -0300 Subject: [PATCH 095/143] refactor: move context logic to (new)ContextPanel for improved context management and UI encapsulation --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 213 +--------------- .../iped/app/ui/ai/view/ContextPanel.java | 233 ++++++++++++++++++ 2 files changed, 246 insertions(+), 200 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index d5d366ca6e..f6fae085ef 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -1,8 +1,6 @@ package iped.app.ui.ai.view; import java.awt.*; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.util.ArrayList; @@ -12,12 +10,9 @@ import javax.swing.*; import javax.swing.border.EmptyBorder; -import javax.swing.border.TitledBorder; -import javax.swing.text.StyledDocument; import iped.app.ui.ai.AIChatCoordinator; import iped.app.ui.ai.model.AIChatMessage; -import iped.app.ui.ai.model.ContextFileEntry; import iped.app.ui.ai.model.Conversation; import iped.app.ui.ai.context.AIContextManager; import iped.app.ui.ai.context.ContextChangeEvent; @@ -51,8 +46,6 @@ public class AIAssistantPanel { private static final int VERTICAL_OFFSET = 120; private static final double HEIGHT_PERCENTAGE = 0.8; private static final int PANEL_WIDTH = 750; - private static final int CONTEXT_VISIBLE_ITEMS = 5; - private static final int CONTEXT_REMOVE_HOTZONE_PX = 28; // Main UI components private JFrame frame; @@ -71,27 +64,12 @@ public class AIAssistantPanel { private AIChatCoordinator coordinator; // Context-related UI components - private JPanel contextPanel; - private JList contextList; - private DefaultListModel contextListModel; - private JLabel contextEmptyLabel; - private JButton clearContextButton; - private TitledBorder contextBorder; - private boolean isSwitchingChats = false; // Prevents the ContextChangeListener from overwriting saved data during chat switching - - private static final class ContextSummaryRow { - private final String text; - - private ContextSummaryRow(String text) { - this.text = text; - } + private ContextPanel contextPanel; - @Override - public String toString() { - return text; - } - } + private final ConversationManager conversationManager; + private final AIContextManager contextManager; + private boolean isSwitchingChats = false; // Prevents the ContextChangeListener from overwriting saved data during chat switching // Singleton instance ensures only one floating panel exists at a time private static AIAssistantPanel instance; @@ -107,25 +85,24 @@ public static AIAssistantPanel getInstance() { * Private constructor: This is the application's wiring hub. */ private AIAssistantPanel() { + this.conversationManager = ConversationManager.getInstance(); + this.contextManager = AIContextManager.getInstance(); createUI(); // Subscribe to the State Manager. // We use the Observer pattern to passively listen for context changes. - AIContextManager.getInstance().addContextChangeListener(new ContextChangeListener() { + this.contextManager.addContextChangeListener(new ContextChangeListener() { @Override public void contextChanged(ContextChangeEvent event) { - // Update the Visual UI - refreshContextUI(); - // Abort if system is currently switching chats if (isSwitchingChats) return; // Real-Time State Sync for Unsaved Drafts - Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); + Conversation activeConv = conversationManager.getActiveConversation(); if (activeConv != null) { // Grab all valid file IDs currently sitting in the UI bucket - List currentIds = AIContextManager.getInstance().getContextFiles().stream() + List currentIds = contextManager.getContextFiles().stream() .map(IItem::getId) .collect(Collectors.toList()); @@ -142,10 +119,10 @@ public void contextChanged(ContextChangeEvent event) { } public void startNewConversationWithCurrentContext(List pendingItems) { - Conversation newConversation = ConversationManager.getInstance().startNewConversation(); + Conversation newConversation = conversationManager.startNewConversation(); List contextIds = new ArrayList<>(); - for (IItem item : AIContextManager.getInstance().getContextFiles()) { + for (IItem item : contextManager.getContextFiles()) { if (item != null && !contextIds.contains(item.getId())) { contextIds.add(item.getId()); } @@ -201,7 +178,8 @@ private void createUI() { JPanel chatWorkspacePanel = new JPanel(new BorderLayout(5, 5)); JPanel centerPanel = new JPanel(new BorderLayout(5, 5)); - centerPanel.add(createContextSection(), BorderLayout.NORTH); + contextPanel = new ContextPanel(contextManager); + centerPanel.add(contextPanel, BorderLayout.NORTH); String sendText = "Send"; try { sendText = Messages.getString("AIAssistant.Send"); } catch (Exception e) {} @@ -489,171 +467,6 @@ else if (conv.getContextIds() != null && !conv.getContextIds().isEmpty()) { sidebarPanel.getConversationList().setSelectedValue(conv, true); } - private JPanel createContextSection() { - contextListModel = new DefaultListModel<>(); - contextList = new JList<>(contextListModel); - contextList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - contextList.setVisibleRowCount(CONTEXT_VISIBLE_ITEMS); - contextList.setBackground(new Color(255, 255, 240)); - - contextList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { - if (value instanceof ContextSummaryRow) { - JLabel label = (JLabel) new DefaultListCellRenderer() - .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); - ContextSummaryRow summary = (ContextSummaryRow) value; - label.setText(summary.toString()); - label.setForeground(Color.DARK_GRAY); - label.setFont(label.getFont().deriveFont(Font.ITALIC)); - label.setToolTipText(summary.toString()); - return label; - } - - if (value instanceof ContextFileEntry) { - return createContextEntryCell(list, (ContextFileEntry) value, isSelected); - } - - JLabel label = (JLabel) new DefaultListCellRenderer() - .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); - label.setText(String.valueOf(value)); - return label; - }); - - contextList.addMouseListener(new MouseAdapter() { - @Override - public void mouseClicked(MouseEvent e) { - if (e.getButton() != MouseEvent.BUTTON1) { - return; - } - - int index = contextList.locationToIndex(e.getPoint()); - if (index < 0 || index >= contextListModel.size()) { - return; - } - - Rectangle cellBounds = contextList.getCellBounds(index, index); - if (cellBounds == null || !cellBounds.contains(e.getPoint())) { - return; - } - - Object value = contextListModel.getElementAt(index); - if (!(value instanceof ContextFileEntry)) { - return; - } - - if (!isRemoveHotzoneClick(e, cellBounds)) { - return; - } - - AIContextManager.getInstance().removeContextFile(((ContextFileEntry) value).getItem()); - } - }); - - JScrollPane contextScroll = new JScrollPane(contextList); - contextScroll.setPreferredSize(new Dimension(PANEL_WIDTH - 10, 80)); - - contextEmptyLabel = new JLabel("No files added to context."); - contextEmptyLabel.setForeground(Color.GRAY); - contextEmptyLabel.setFont(new Font("SansSerif", Font.ITALIC, 11)); - contextEmptyLabel.setHorizontalAlignment(SwingConstants.CENTER); - - JPanel listContainer = new JPanel(new BorderLayout()); - listContainer.add(contextScroll, BorderLayout.CENTER); - listContainer.add(contextEmptyLabel, BorderLayout.NORTH); - - clearContextButton = new JButton("Clear"); - clearContextButton.setMargin(new Insets(0, 5, 0, 5)); - clearContextButton.setEnabled(false); - clearContextButton.addActionListener(e -> AIContextManager.getInstance().clearContext()); - - JPanel actionPanel = new JPanel(new BorderLayout()); - actionPanel.add(clearContextButton, BorderLayout.NORTH); - - contextPanel = new JPanel(new BorderLayout(5, 5)); - contextBorder = BorderFactory.createTitledBorder("Added Context (0 files)"); - contextPanel.setBorder(contextBorder); - - contextPanel.add(listContainer, BorderLayout.CENTER); - contextPanel.add(actionPanel, BorderLayout.EAST); - - refreshContextUI(); - - return contextPanel; - } - - /** - * Destructive refresh: Wipes the UI list and rebuilds it from the Source of Truth. - */ - private void refreshContextUI() { - List entries = AIContextManager.getInstance().getContextEntriesForUI(); - List validFiles = AIContextManager.getInstance().getContextFiles(); - int invalidCount = entries.size() - validFiles.size(); - contextListModel.clear(); - - if (entries.isEmpty()) { - contextEmptyLabel.setVisible(true); - contextList.setVisible(false); - clearContextButton.setEnabled(false); - } else { - contextEmptyLabel.setVisible(false); - contextList.setVisible(true); - clearContextButton.setEnabled(true); - - int visibleCount = Math.min(CONTEXT_VISIBLE_ITEMS, entries.size()); - for (int i = 0; i < visibleCount; i++) { - contextListModel.addElement(entries.get(i)); - } - - if (entries.size() > CONTEXT_VISIBLE_ITEMS) { - int hiddenCount = entries.size() - CONTEXT_VISIBLE_ITEMS; - String summaryText = "+ " + hiddenCount + " more items (" + validFiles.size() + " valid, " + invalidCount + " rejected)"; - contextListModel.addElement(new ContextSummaryRow(summaryText)); - } - } - - if (invalidCount > 0) { - contextBorder.setTitle("Added Context (" + validFiles.size() + " valid, " + invalidCount + " rejected)"); - } else { - contextBorder.setTitle("Added Context (" + validFiles.size() + " valid)"); - } - - contextPanel.repaint(); - } - - private JComponent createContextEntryCell(JList list, ContextFileEntry entry, boolean isSelected) { - JPanel rowPanel = new JPanel(new BorderLayout(8, 0)); - rowPanel.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8)); - rowPanel.setOpaque(true); - rowPanel.setBackground(isSelected ? list.getSelectionBackground() : list.getBackground()); - - JLabel textLabel = new JLabel(); - textLabel.setOpaque(false); - textLabel.setFont(list.getFont()); - textLabel.setForeground(isSelected ? list.getSelectionForeground() : list.getForeground()); - - JLabel removeLabel = new JLabel("X"); - removeLabel.setOpaque(false); - removeLabel.setFont(list.getFont().deriveFont(Font.BOLD)); - removeLabel.setForeground(new Color(160, 0, 0)); - - if (entry.isValidForContext()) { - textLabel.setText(entry.getFileName()); - rowPanel.setToolTipText(entry.getFullPath()); - } else { - String reason = entry.getValidationReason() != null ? entry.getValidationReason() : "Rejected item."; - textLabel.setText(entry.getFileName() + " - " + reason); - rowPanel.setToolTipText(reason + " Path: " + entry.getFullPath()); - textLabel.setForeground(isSelected ? list.getSelectionForeground() : new Color(180, 0, 0)); - } - - rowPanel.add(textLabel, BorderLayout.CENTER); - rowPanel.add(removeLabel, BorderLayout.EAST); - return rowPanel; - } - - private boolean isRemoveHotzoneClick(MouseEvent e, Rectangle cellBounds) { - return e.getX() >= cellBounds.x + cellBounds.width - CONTEXT_REMOVE_HOTZONE_PX; - } - // Quick actions panel private JPanel createTasksPanel() { JPanel panel = new JPanel(); diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java new file mode 100644 index 0000000000..7cd02a1709 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java @@ -0,0 +1,233 @@ +package iped.app.ui.ai.view; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Insets; +import java.awt.Rectangle; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.List; + +import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ListSelectionModel; +import javax.swing.SwingConstants; +import javax.swing.border.TitledBorder; + +import iped.app.ui.ai.context.AIContextManager; +import iped.app.ui.ai.model.ContextFileEntry; +import iped.data.IItem; + +/** + * Componente encapsulado responsável por exibir e gerenciar a interface de arquivos + * adicionados ao contexto da IA. Aplica SRP e reage diretamente ao AIContextManager. + */ +public class ContextPanel extends JPanel { + + private static final int CONTEXT_VISIBLE_ITEMS = 5; + private static final int CONTEXT_REMOVE_HOTZONE_PX = 28; + private static final int PANEL_WIDTH = 750; + + private JList contextList; + private DefaultListModel contextListModel; + private JLabel contextEmptyLabel; + private JButton clearContextButton; + private TitledBorder contextBorder; + + private final AIContextManager contextManager; + + /** + * Classe utilitária interna para renderização da linha de estouro de capacidade. + */ + private static final class ContextSummaryRow { + private final String text; + + private ContextSummaryRow(String text) { + this.text = text; + } + + @Override + public String toString() { + return text; + } + } + + public ContextPanel(AIContextManager contextManager) { + this.contextManager = contextManager; + + configureLayout(); + initComponents(); + setupContextListener(); + refreshContextUI(); + } + + private void configureLayout() { + setLayout(new BorderLayout(5, 5)); + contextBorder = BorderFactory.createTitledBorder("Added Context (0 files)"); + setBorder(contextBorder); + } + + private void initComponents() { + contextListModel = new DefaultListModel<>(); + contextList = new JList<>(contextListModel); + contextList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + contextList.setVisibleRowCount(CONTEXT_VISIBLE_ITEMS); + contextList.setBackground(new Color(255, 255, 240)); + + setupCellRenderer(); + setupMouseListener(); + + JScrollPane contextScroll = new JScrollPane(contextList); + contextScroll.setPreferredSize(new Dimension(PANEL_WIDTH - 10, 80)); + + contextEmptyLabel = new JLabel("No files added to context."); + contextEmptyLabel.setForeground(Color.GRAY); + contextEmptyLabel.setFont(new Font("SansSerif", Font.ITALIC, 11)); + contextEmptyLabel.setHorizontalAlignment(SwingConstants.CENTER); + + JPanel listContainer = new JPanel(new BorderLayout()); + listContainer.add(contextScroll, BorderLayout.CENTER); + listContainer.add(contextEmptyLabel, BorderLayout.NORTH); + + clearContextButton = new JButton("Clear"); + clearContextButton.setMargin(new Insets(0, 5, 0, 5)); + clearContextButton.setEnabled(false); + clearContextButton.addActionListener(e -> contextManager.clearContext()); + + JPanel actionPanel = new JPanel(new BorderLayout()); + actionPanel.add(clearContextButton, BorderLayout.NORTH); + + add(listContainer, BorderLayout.CENTER); + add(actionPanel, BorderLayout.EAST); + } + + private void setupCellRenderer() { + contextList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { + if (value instanceof ContextSummaryRow) { + JLabel label = (JLabel) new DefaultListCellRenderer() + .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + ContextSummaryRow summary = (ContextSummaryRow) value; + label.setText(summary.toString()); + label.setForeground(Color.DARK_GRAY); + label.setFont(label.getFont().deriveFont(Font.ITALIC)); + label.setToolTipText(summary.toString()); + return label; + } + + if (value instanceof ContextFileEntry) { + return createContextEntryCell(list, (ContextFileEntry) value, isSelected); + } + + JLabel label = (JLabel) new DefaultListCellRenderer() + .getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + label.setText(String.valueOf(value)); + return label; + }); + } + + private JComponent createContextEntryCell(JList list, ContextFileEntry entry, boolean isSelected) { + JPanel rowPanel = new JPanel(new BorderLayout(8, 0)); + rowPanel.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8)); + rowPanel.setOpaque(true); + rowPanel.setBackground(isSelected ? list.getSelectionBackground() : list.getBackground()); + + JLabel textLabel = new JLabel(); + textLabel.setOpaque(false); + textLabel.setFont(list.getFont()); + textLabel.setForeground(isSelected ? list.getSelectionForeground() : list.getForeground()); + + JLabel removeLabel = new JLabel("X"); + removeLabel.setOpaque(false); + removeLabel.setFont(list.getFont().deriveFont(Font.BOLD)); + removeLabel.setForeground(new Color(160, 0, 0)); + + if (entry.isValidForContext()) { + textLabel.setText(entry.getFileName()); + rowPanel.setToolTipText(entry.getFullPath()); + } else { + String reason = entry.getValidationReason() != null ? entry.getValidationReason() : "Rejected item."; + textLabel.setText(entry.getFileName() + " - " + reason); + rowPanel.setToolTipText(reason + " Path: " + entry.getFullPath()); + textLabel.setForeground(isSelected ? list.getSelectionForeground() : new Color(180, 0, 0)); + } + + rowPanel.add(textLabel, BorderLayout.CENTER); + rowPanel.add(removeLabel, BorderLayout.EAST); + return rowPanel; + } + + private void setupMouseListener() { + contextList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() != MouseEvent.BUTTON1) return; + + int index = contextList.locationToIndex(e.getPoint()); + if (index < 0 || index >= contextListModel.size()) return; + + Rectangle cellBounds = contextList.getCellBounds(index, index); + if (cellBounds == null || !cellBounds.contains(e.getPoint())) return; + + Object value = contextListModel.getElementAt(index); + if (!(value instanceof ContextFileEntry)) return; + + if (e.getX() < cellBounds.x + cellBounds.width - CONTEXT_REMOVE_HOTZONE_PX) return; + + contextManager.removeContextFile(((ContextFileEntry) value).getItem()); + } + }); + } + + private void setupContextListener() { + // O próprio painel se auto-escuta para redesenhar a lista quando o estado mudar + contextManager.addContextChangeListener(event -> refreshContextUI()); + } + + /** + * Sincroniza a interface visual de arquivos com o estado real do domínio. + */ + public void refreshContextUI() { + List entries = contextManager.getContextEntriesForUI(); + List validFiles = contextManager.getContextFiles(); + int invalidCount = entries.size() - validFiles.size(); + contextListModel.clear(); + + if (entries.isEmpty()) { + contextEmptyLabel.setVisible(true); + contextList.setVisible(false); + clearContextButton.setEnabled(false); + } else { + contextEmptyLabel.setVisible(false); + contextList.setVisible(true); + clearContextButton.setEnabled(true); + + int visibleCount = Math.min(CONTEXT_VISIBLE_ITEMS, entries.size()); + for (int i = 0; i < visibleCount; i++) { + contextListModel.addElement(entries.get(i)); + } + + if (entries.size() > CONTEXT_VISIBLE_ITEMS) { + int hiddenCount = entries.size() - CONTEXT_VISIBLE_ITEMS; + String summaryText = "+ " + hiddenCount + " more items (" + validFiles.size() + " valid, " + invalidCount + " rejected)"; + contextListModel.addElement(new ContextSummaryRow(summaryText)); + } + } + + if (invalidCount > 0) { + contextBorder.setTitle("Added Context (" + validFiles.size() + " valid, " + invalidCount + " rejected)"); + } else { + contextBorder.setTitle("Added Context (" + validFiles.size() + " valid)"); + } + + repaint(); + } +} \ No newline at end of file From 5ff4d20b1ef3e97e54b88d941e49566912d5206b Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 19 May 2026 14:23:33 -0300 Subject: [PATCH 096/143] doc: translate comments language to english --- .../main/java/iped/app/ui/ai/view/ContextPanel.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java index 7cd02a1709..a7c19274c1 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java @@ -28,8 +28,8 @@ import iped.data.IItem; /** - * Componente encapsulado responsável por exibir e gerenciar a interface de arquivos - * adicionados ao contexto da IA. Aplica SRP e reage diretamente ao AIContextManager. + * Encapsulated component responsible for displaying and managing the interface + * for files added to the AI context. Applies SRP and reacts directly to the AIContextManager. */ public class ContextPanel extends JPanel { @@ -46,7 +46,7 @@ public class ContextPanel extends JPanel { private final AIContextManager contextManager; /** - * Classe utilitária interna para renderização da linha de estouro de capacidade. + * Internal utility class responsible for rendering the capacity overflow line. */ private static final class ContextSummaryRow { private final String text; @@ -188,12 +188,12 @@ public void mouseClicked(MouseEvent e) { } private void setupContextListener() { - // O próprio painel se auto-escuta para redesenhar a lista quando o estado mudar + // The panel itself listens to its own state to redraw the list when the state changes contextManager.addContextChangeListener(event -> refreshContextUI()); } /** - * Sincroniza a interface visual de arquivos com o estado real do domínio. + * Synchronizes the file UI with the actual domain state. */ public void refreshContextUI() { List entries = contextManager.getContextEntriesForUI(); From 3b010e529e1f7aba4f91ca1ef1f46bb98deb0195 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 19 May 2026 16:37:06 -0300 Subject: [PATCH 097/143] # refactor(ai-ui): standardize context manager and encapsulate sidebar refresh - Moved `refreshSidebarList` execution into `SidebarPanel.java` to align with current class boundaries. - Replaced scattered `AIContextManager.getInstance()` calls with the properly injected `contextManager` instance to ensure singleton consistency. - Standardized comments and imports across the AI UI package. --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 26 +++++++------------ .../iped/app/ui/ai/view/ChatAreaPanel.java | 24 +++++++++-------- .../iped/app/ui/ai/view/SidebarPanel.java | 10 +++---- 3 files changed, 28 insertions(+), 32 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index f6fae085ef..21a5f19e8d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -142,14 +142,14 @@ public void startNewConversationWithCurrentContext(List pendingItems) { newConversation.updateLastModified(); if (pendingItems != null) { - AIContextManager.getInstance().addContextFiles(pendingItems); + contextManager.addContextFiles(pendingItems); } if (coordinator != null) { coordinator.clearHistory(); } - refreshSidebarList(); + sidebarPanel.refreshSidebarList(); refreshChatArea(); sidebarPanel.getConversationList().setSelectedValue(newConversation, true); showFrame(); @@ -230,7 +230,7 @@ public void onConversationDeleted(Conversation conversation, boolean isActiveDel loadConversation(active); } else { clearChatScreenAndMemory(); - AIContextManager.getInstance().clearContext(); + contextManager.clearContext(); refreshChatArea(); } } @@ -252,7 +252,7 @@ public void onConversationDeleted(Conversation conversation, boolean isActiveDel addMessage("System", "AI Assistant ready. Connected to local Backend server.\nRight-click an HTML WhatsApp chat export to add it to the context, then type your question."); - refreshSidebarList(); + sidebarPanel.refreshSidebarList(); } private void navigateToItem(String hash, String chunkId) { @@ -334,12 +334,6 @@ private void toggleSidebar() { } } - private void refreshSidebarList() { - if (sidebarPanel != null) { - sidebarPanel.refreshList(); - } - } - private void startNewChat() { // Create a new active conversation in memory first (safeguards the old one) ConversationManager.getInstance().startNewConversation(); @@ -348,9 +342,9 @@ private void startNewChat() { clearChatScreenAndMemory(); // Clear IPED context - AIContextManager.getInstance().clearContext(); + contextManager.clearContext(); - refreshSidebarList(); + sidebarPanel.refreshSidebarList(); refreshChatArea(); addMessage("System", "Started a new conversation session."); @@ -374,7 +368,7 @@ private void loadConversation(Conversation conv) { } // Restore the visual IPED Context UI - AIContextManager.getInstance().clearContext(); // Wipe previous chat's files + contextManager.clearContext(); // Wipe previous chat's files new Thread(() -> { List restoredItems = new ArrayList<>(); @@ -447,7 +441,7 @@ else if (conv.getContextIds() != null && !conv.getContextIds().isEmpty()) { } // Restore the visual UI - AIContextManager.getInstance().addContextFiles(restoredItems); + contextManager.addContextFiles(restoredItems); } } finally { // Unlock the listener on the EDT @@ -646,7 +640,7 @@ private void handleSendAction() { // If the user deleted all chats and types in the blank slate, start a new one if (ConversationManager.getInstance().getActiveConversation() == null) { ConversationManager.getInstance().startNewConversation(); - refreshSidebarList(); + sidebarPanel.refreshSidebarList(); } // Print user message immediately @@ -673,7 +667,7 @@ private void handleSendAction() { if (!assistantDraft.getContent().isEmpty()) { // Registra a resposta final estável no modelo de dados histórico do IPED ConversationManager.getInstance().addMessageToActive(assistantDraft); - refreshSidebarList(); + sidebarPanel.refreshSidebarList(); } setProcessing(false); }); diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java index 6b4d906d0b..9706462806 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java @@ -1,11 +1,13 @@ package iped.app.ui.ai.view; -import java.awt.Dimension; import java.util.List; import java.util.function.BiConsumer; +import java.awt.Dimension; import java.awt.Color; import java.awt.Cursor; import java.awt.BorderLayout; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; import javax.swing.BorderFactory; import javax.swing.JButton; @@ -19,6 +21,8 @@ import javax.swing.SwingUtilities; import javax.swing.text.BadLocationException; import javax.swing.text.StyledDocument; +import javax.swing.text.AttributeSet; +import javax.swing.text.Element; import iped.app.ui.ai.model.AIChatMessage; @@ -212,14 +216,14 @@ public void installTextPaneClickListener(BiConsumer navigationCa if (chatArea == null) { return; } - chatArea.addMouseListener(new java.awt.event.MouseAdapter() { + chatArea.addMouseListener(new MouseAdapter() { @Override - public void mouseClicked(java.awt.event.MouseEvent e) { - if (e.getButton() != java.awt.event.MouseEvent.BUTTON1) { + public void mouseClicked(MouseEvent e) { + if (e.getButton() != MouseEvent.BUTTON1) { return; } - // converts the clicked point to a document offset + // Converts the clicked point to a document offset int offset = chatArea.viewToModel2D(e.getPoint()); StyledDocument currentDoc = getChatDocument(); if (offset < 0 || currentDoc == null) { @@ -227,11 +231,11 @@ public void mouseClicked(java.awt.event.MouseEvent e) { } - javax.swing.text.Element element = chatDocument.getCharacterElement(offset); - javax.swing.text.AttributeSet attributes = element.getAttributes(); + Element element = chatDocument.getCharacterElement(offset); + AttributeSet attributes = element.getAttributes(); Object tokenFlag = attributes.getAttribute(AIMarkdownRenderer.TOKEN_ATTRIBUTE); - // 1. clicks on tokens with navigation metadata (e.g., hash and chunkId) + // Clicks on tokens with navigation metadata (e.g., hash and chunkId) if (Boolean.TRUE.equals(tokenFlag)) { int start = element.getStartOffset(); int end = element.getEndOffset(); @@ -248,7 +252,7 @@ public void mouseClicked(java.awt.event.MouseEvent e) { return; } - // 2. clicks on other tokens without navigation metadata will toggle "thinking" state for that token + // Clicks on other tokens without navigation metadata will toggle "thinking" state for that token if (markdownRenderer != null && markdownRenderer.toggleThinkingAtOffset(offset)) { if (refreshCallback != null) { refreshCallback.run(); @@ -256,7 +260,5 @@ public void mouseClicked(java.awt.event.MouseEvent e) { } } }); - } - } diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java index 3f7a379c0e..b619c5ebe3 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java @@ -158,7 +158,7 @@ public void setConversationList(JList conversationList) { /** * Sincronized method to refresh the conversation list UI based on the current state of ConversationManager. */ - public void refreshList() { + public void refreshSidebarList() { conversationListModel.clear(); for (Conversation conv : conversationManager.getConversations()) { conversationListModel.addElement(conv); @@ -178,14 +178,14 @@ private void promptDeleteConversation(Conversation conv) { JOptionPane.WARNING_MESSAGE); if (confirm == JOptionPane.YES_OPTION) { - // Remove logicamente e fisicamente + // Removes logically and physically ConversationPersistence.deleteConversation(conv.getId()); conversationManager.removeConversation(conv); Conversation active = conversationManager.getActiveConversation(); boolean isActiveDeleted = (active == null || active.getId().equals(conv.getId())); - // Reposiciona o ponteiro de conversas caso a conversa ativa tenha sido deletada + // Repositions the conversation pointer if the active conversation has been deleted if (isActiveDeleted) { List remaining = ConversationManager.getInstance().getConversations(); if (!remaining.isEmpty()) { @@ -195,12 +195,12 @@ private void promptDeleteConversation(Conversation conv) { } } - // Notifica o listener externo para atualizações colaterais (Ex: limpar tela) + // Notifies the external listener of side updates (e.g., clear screen) if (listener != null) { listener.onConversationDeleted(conv, isActiveDeleted); } - refreshList(); + refreshSidebarList(); } } } \ No newline at end of file From 998bc067273c41a79c993c487744d501c89384ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 21 May 2026 16:28:12 -0300 Subject: [PATCH 098/143] Refactor: make the panel components purely visual, and route all events through the controller. --- .../ai/controller/AIAssistantController.java | 491 ++++++++++++++ .../iped/app/ui/ai/view/AIAssistantPanel.java | 633 +++--------------- .../iped/app/ui/ai/view/ContextPanel.java | 72 +- .../iped/app/ui/ai/view/SidebarPanel.java | 60 +- 4 files changed, 655 insertions(+), 601 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java new file mode 100644 index 0000000000..0b6bb2e243 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -0,0 +1,491 @@ +package iped.app.ui.ai.controller; + +import java.awt.Component; +import java.awt.Dimension; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; + +import iped.app.ui.App; +import iped.app.ui.Messages; +import iped.app.ui.ai.AIChatCoordinator; +import iped.app.ui.ai.backend.AIBackendClient; +import iped.app.ui.ai.backend.AIBackendConfig; +import iped.app.ui.ai.context.AIContextManager; +import iped.app.ui.ai.context.ContextChangeEvent; +import iped.app.ui.ai.context.ContextChangeListener; +import iped.app.ui.ai.context.ConversationManager; +import iped.app.ui.ai.model.AIChatMessage; +import iped.app.ui.ai.model.Conversation; +import iped.app.ui.ai.util.ConversationPersistence; +import iped.app.ui.ai.view.AIAssistantPanel; +import iped.app.ui.ai.view.ChatAreaPanel; +import iped.app.ui.ai.view.ContextPanel; +import iped.app.ui.ai.view.HeaderPanel; +import iped.app.ui.ai.view.SidebarPanel; +import iped.data.IItem; +import iped.data.IItemId; +import iped.engine.search.IPEDSearcher; +import iped.engine.search.MultiSearchResult; + +/** + * Main controller for the AI Assistant feature, responsible for orchestrating interactions between the UI and the AI backend. + */ +public class AIAssistantController { + + private final AIAssistantPanel mainView; + private SidebarPanel sidebarView; + private ChatAreaPanel chatAreaView; + private ContextPanel contextView; + private HeaderPanel headerView; + private JPanel tasksView; + + private AIChatCoordinator coordinator; + private final ConversationManager conversationManager; + private final AIContextManager contextManager; + + private boolean isSwitchingChats = false; + + /** + * Constructor that injects only the main view, keeping things decoupled. + * @param mainView The main layout container. + */ + public AIAssistantController(AIAssistantPanel mainView) { + this.mainView = mainView; + this.conversationManager = ConversationManager.getInstance(); + this.contextManager = AIContextManager.getInstance(); + } + + /** + * Entry point called by the main view to assemble the UI and logic. + */ + public void initialize() { + // 1. Initialize essential dependencies + ensureChatServiceInitialized(); + + // 2. Instantiate sub-panels and inject listeners + initViews(); + + // 3. Configure global domain observers + setupStateObservers(); + + // 4. Hand off the constructed views so the main panel can layout + mainView.assembleLayout(headerView, sidebarView, contextView, chatAreaView, tasksView); + + // 5. Render initial state (messages, lists, context, etc.) + sidebarView.updateConversationsList(conversationManager.getConversations()); + contextView.updateContextData(contextManager.getContextEntriesForUI()); + refreshChatArea(); + } + + private void initViews() { + // Initialize sidebar (injecting click-handling logic) + this.sidebarView = new SidebarPanel(mainView.getFrame(), new SidebarPanel.SidebarListener() { + @Override + public void onNewChatRequested() { + startNewChat(); + } + + @Override + public void onDeleteRequested(Conversation conversation) { + deleteChat(conversation); + } + + @Override + public void onConversationSelected(Conversation conversation) { + loadConversation(conversation); + } + }); + + // Initialize header + String title = "AI Assistant"; + try { title = Messages.getString("AIAssistant.Title"); } catch (Exception e) {} + this.headerView = new HeaderPanel(title, e -> mainView.toggleSidebar()); + + // Initialize context panel with injected behavior (listener) + this.contextView = new ContextPanel(new ContextPanel.ContextListener() { + @Override + public void onClearContextRequested() { + contextManager.clearContext(); + } + + @Override + public void onRemoveFileRequested(IItem item) { + contextManager.removeContextFile(item); + } + }); + + // Initialize chat area + String sendText = "Send"; + try { sendText = Messages.getString("AIAssistant.Send"); } catch (Exception e) {} + this.chatAreaView = new ChatAreaPanel(750, sendText); + + // Wire chat area send events to the controller + this.chatAreaView.getSendButton().addActionListener(e -> handleSendAction()); + this.chatAreaView.getInputArea().addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER && !e.isShiftDown()) { + e.consume(); + handleSendAction(); + } + } + }); + + // Delegate token click handling back to IPED (open file) + this.chatAreaView.installTextPaneClickListener( + (hash, chunkId) -> navigateToItem(hash, chunkId), + this::refreshChatArea + ); + + // Initialize right-side quick tasks panel + this.tasksView = createTasksPanel(); + } + + private void setupStateObservers() { + this.contextManager.addContextChangeListener(new ContextChangeListener() { + @Override + public void contextChanged(ContextChangeEvent event) { + // 1. Inject new data into the passive view for rendering on the correct thread + SwingUtilities.invokeLater(() -> { + if (contextView != null) { + contextView.updateContextData(contextManager.getContextEntriesForUI()); + } + }); + + // 2. Domain persistence logic + if (isSwitchingChats) return; // Abort if the system is switching chats + + Conversation activeConv = conversationManager.getActiveConversation(); + if (activeConv != null) { + List currentIds = contextManager.getContextFiles().stream() + .map(IItem::getId) + .collect(Collectors.toList()); + + activeConv.setContextIds(currentIds); + activeConv.updateLastModified(); + + final Conversation convToSave = activeConv; + new Thread(() -> ConversationPersistence.saveConversation(convToSave)).start(); + } + } + }); + } + + private boolean ensureChatServiceInitialized() { + if (coordinator != null) { + return true; + } + try { + coordinator = new AIChatCoordinator(new AIBackendClient(AIBackendConfig.loadFromSystemProperties())); + return true; + } catch (Throwable t) { + addMessage("System Error", "Failed to initialize AI backend: " + t.getMessage(), "error"); + t.printStackTrace(); + return false; + } + } + + // ======================================================================== + // SIDEBAR BUSINESS LOGIC + // ======================================================================== + + private void startNewChat() { + conversationManager.startNewConversation(); + clearChatScreenAndMemory(); + contextManager.clearContext(); + sidebarView.updateConversationsList(conversationManager.getConversations()); + refreshChatArea(); + + addMessage("System", "Started a new conversation session.", "system"); + chatAreaView.getInputArea().requestFocusInWindow(); + } + + private void deleteChat(Conversation conv) { + ConversationPersistence.deleteConversation(conv.getId()); + conversationManager.removeConversation(conv); + + Conversation active = conversationManager.getActiveConversation(); + boolean isActiveDeleted = (active == null || active.getId().equals(conv.getId())); + + if (isActiveDeleted) { + List remaining = conversationManager.getConversations(); + if (!remaining.isEmpty()) { + conversationManager.setActiveConversation(remaining.get(0)); + loadConversation(remaining.get(0)); + } else { + conversationManager.setActiveConversation(null); + clearChatScreenAndMemory(); + contextManager.clearContext(); + refreshChatArea(); + } + } + + sidebarView.updateConversationsList(conversationManager.getConversations()); + } + + private void loadConversation(Conversation conv) { + isSwitchingChats = true; + conversationManager.setActiveConversation(conv); + + if (coordinator != null) { + coordinator.loadHistoricalContext(conv.getChatHashes(), conv.getContextIds(), conv.getMessages()); + } + + contextManager.clearContext(); + + new Thread(() -> { + List restoredItems = new ArrayList<>(); + try { + if (conv.getChatHashes() != null && !conv.getChatHashes().isEmpty()) { + for (String hash : conv.getChatHashes()) { + try { + IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); + MultiSearchResult result = searcher.multiSearch(); + if (result != null && result.getLength() > 0) { + IItemId qualifiedItemId = result.getItem(0); + IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); + if (item != null) restoredItems.add(item); + } + } catch (Exception e) { + System.err.println("Could not restore context item hash: " + hash); + } + } + } else if (conv.getContextIds() != null && !conv.getContextIds().isEmpty()) { + for (Integer itemId : conv.getContextIds()) { + try { + IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "id:" + itemId); + MultiSearchResult result = searcher.multiSearch(); + if (result != null && result.getLength() > 0) { + IItemId qualifiedItemId = result.getItem(0); + IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); + if (item != null) restoredItems.add(item); + } + } catch (Exception e) { + System.err.println("Could not restore context item ID: " + itemId); + } + } + } + } finally { + SwingUtilities.invokeLater(() -> { + try { + if (!restoredItems.isEmpty()) { + Conversation currentActive = conversationManager.getActiveConversation(); + if (currentActive == null || !currentActive.getId().equals(conv.getId())) { + return; + } + List freshIds = restoredItems.stream() + .map(IItem::getId).collect(Collectors.toList()); + + if (coordinator != null) { + coordinator.loadHistoricalContext(conv.getChatHashes(), freshIds, conv.getMessages()); + } + contextManager.addContextFiles(restoredItems); + } + } finally { + isSwitchingChats = false; + } + }); + } + }).start(); + + if (chatAreaView != null) chatAreaView.forceDiscardStreaming(); + + refreshChatArea(); + sidebarView.setSelectedValue(conv, true); + } + + public void startNewConversationWithCurrentContext(List pendingItems) { + Conversation newConversation = conversationManager.startNewConversation(); + + List contextIds = new ArrayList<>(); + for (IItem item : contextManager.getContextFiles()) { + if (item != null && !contextIds.contains(item.getId())) { + contextIds.add(item.getId()); + } + } + + if (pendingItems != null) { + for (IItem item : pendingItems) { + if (item != null && !contextIds.contains(item.getId())) { + contextIds.add(item.getId()); + } + } + } + + newConversation.setContextIds(contextIds); + newConversation.setChatHashes(new ArrayList<>()); + newConversation.setMessages(new ArrayList<>()); + newConversation.updateLastModified(); + + if (pendingItems != null) { + contextManager.addContextFiles(pendingItems); + } + + if (coordinator != null) coordinator.clearHistory(); + + sidebarView.updateConversationsList(conversationManager.getConversations()); + refreshChatArea(); + sidebarView.setSelectedValue(newConversation, true); + mainView.showFrame(); + } + + // ======================================================================== + // CHAT / NETWORK BUSINESS LOGIC + // ======================================================================== + + private void handleSendAction() { + String text = chatAreaView.getInputArea().getText().trim(); + if (text.isEmpty()) return; + + if (!ensureChatServiceInitialized()) return; + + if (conversationManager.getActiveConversation() == null) { + conversationManager.startNewConversation(); + sidebarView.updateConversationsList(conversationManager.getConversations()); + } + + addMessage("You", text, "user"); + chatAreaView.getInputArea().setText(""); + mainView.setProcessing(true); + + AIChatMessage assistantDraft = AIChatMessage.now("Assistant", "", "assistant"); + chatAreaView.startMessageStreaming(assistantDraft); + + coordinator.askQuestion( + text, + (token) -> SwingUtilities.invokeLater(() -> chatAreaView.enqueueStreamingToken(token)), + () -> SwingUtilities.invokeLater(() -> { + chatAreaView.pruneStreaming(() -> { + if (!assistantDraft.getContent().isEmpty()) { + conversationManager.addMessageToActive(assistantDraft); + sidebarView.updateConversationsList(conversationManager.getConversations()); + } + mainView.setProcessing(false); + }); + }), + (errorMessage) -> SwingUtilities.invokeLater(() -> { + chatAreaView.forceDiscardStreaming(); + addMessage("System Error", errorMessage, "error"); + mainView.setProcessing(false); + }) + ); + } + + private void addMessage(String sender, String message, String type) { + AIChatMessage chatMessage = AIChatMessage.now(sender, message, type); + conversationManager.addMessageToActive(chatMessage); + refreshChatArea(); + } + + private void refreshChatArea() { + if (chatAreaView != null) { + List renderableMessages = new ArrayList<>(); + Conversation activeConv = conversationManager.getActiveConversation(); + if (activeConv != null) { + renderableMessages.addAll(activeConv.getMessages()); + } + if (chatAreaView.getCurrentDraftMessage() != null) { + renderableMessages.add(chatAreaView.getCurrentDraftMessage()); + } + chatAreaView.renderHistoricalMessages(renderableMessages); + } + } + + private void clearChatScreenAndMemory() { + if (coordinator != null) coordinator.clearHistory(); + if (chatAreaView != null) chatAreaView.clearChatScreen(); + } + + // ======================================================================== + // NAVIGATION AND HELPER UTILITIES + // ======================================================================== + + private void navigateToItem(String hash, String chunkId) { + if (hash == null || hash.isEmpty() || App.get() == null || App.get().appCase == null) return; + + new Thread(() -> { + try { + IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); + MultiSearchResult result = searcher.multiSearch(); + if (result == null || result.getLength() == 0) { + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(mainView.getFrame(), + "Item not found for hash: " + hash, "Not found", JOptionPane.INFORMATION_MESSAGE)); + return; + } + + IItemId itemId = result.getItem(0); + int luceneId = App.get().appCase.getLuceneId(itemId); + + if (chunkId != null && !chunkId.isEmpty()) { + try { + App.get().getViewerController().getHtmlLinkViewer().setElementIDToScroll(chunkId); + } catch (Exception e) {} + } + + iped.app.ui.FileProcessor fp = new iped.app.ui.FileProcessor(luceneId, true); + fp.execute(); + + SwingUtilities.invokeLater(() -> selectItemInResultsTable(luceneId)); + + } catch (Exception ex) { + ex.printStackTrace(); + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(mainView.getFrame(), + "Error opening item: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE)); + } + }).start(); + } + + private void selectItemInResultsTable(int luceneId) { + if (App.get() == null || App.get().getResults() == null || App.get().getResultsTable() == null) return; + + for (int i = 0; i < App.get().getResults().getLength(); i++) { + try { + IItemId item = App.get().getResults().getItem(i); + if (App.get().appCase.getLuceneId(item) == luceneId) { + int viewIndex = App.get().getResultsTable().convertRowIndexToView(i); + App.get().getResultsTable().setRowSelectionInterval(viewIndex, viewIndex); + java.awt.Rectangle cellRect = App.get().getResultsTable().getCellRect(viewIndex, 0, false); + App.get().getResultsTable().scrollRectToVisible(cellRect); + break; + } + } catch (Exception e) {} + } + } + + private JPanel createTasksPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(BorderFactory.createTitledBorder("Quick Actions")); + + Map taskPrompts = new java.util.HashMap<>(); + taskPrompts.put("Summarize", "Resuma o arquivo fornecido."); + taskPrompts.put("Find Patterns", "Encontre padrões no arquivo fornecido."); + + String[] tasks = {"Summarize", "Find Patterns"}; + for (String task : tasks) { + JButton btn = new JButton(task); + btn.setAlignmentX(Component.CENTER_ALIGNMENT); + btn.setMaximumSize(new Dimension(200, 30)); + btn.addActionListener(e -> { + chatAreaView.getInputArea().setText(taskPrompts.get(task)); + handleSendAction(); + }); + panel.add(btn); + panel.add(Box.createVerticalStrut(5)); + } + + return panel; + } +} \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 21a5f19e8d..ef023f54f6 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -1,80 +1,60 @@ package iped.app.ui.ai.view; -import java.awt.*; -import java.awt.event.KeyAdapter; -import java.awt.event.KeyEvent; -import java.util.ArrayList; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.GraphicsConfiguration; +import java.awt.GraphicsDevice; +import java.awt.GraphicsEnvironment; +import java.awt.Rectangle; +import java.awt.Window; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import javax.swing.*; +import javax.swing.JFrame; +import javax.swing.JPanel; +import javax.swing.JSplitPane; +import javax.swing.SwingUtilities; +import javax.swing.WindowConstants; import javax.swing.border.EmptyBorder; -import iped.app.ui.ai.AIChatCoordinator; -import iped.app.ui.ai.model.AIChatMessage; -import iped.app.ui.ai.model.Conversation; -import iped.app.ui.ai.context.AIContextManager; -import iped.app.ui.ai.context.ContextChangeEvent; -import iped.app.ui.ai.context.ContextChangeListener; -import iped.app.ui.ai.context.ConversationManager; -import iped.app.ui.ai.backend.AIBackendClient; -import iped.app.ui.ai.backend.AIBackendConfig; -import iped.app.ui.ai.util.ConversationPersistence; - import iped.app.ui.App; import iped.app.ui.Messages; -import iped.app.ui.FileProcessor; import iped.data.IItem; -import iped.data.IItemId; -import iped.engine.search.IPEDSearcher; -import iped.engine.search.MultiSearchResult; +import iped.app.ui.ai.controller.AIAssistantController; /** * AI Assistant floating panel UI layer for IPED. *

- * This class acts as the "View" in our architecture. It is a Singleton that - * provides a floating panel containing the chat display, file context management, - * and progress indicators. It is entirely decoupled from the extraction and - * network logic, communicating only through the AIChatCoordinator. + * This class acts strictly as the top-level Container (View) in the MVP/MVC architecture. + * Responsibility is isolated entirely to layout arrangement and window lifecycle management, + * completely decoupled from business, extraction, and network logic. *

*/ public class AIAssistantPanel { - // UI positioning constants private static final int HORIZONTAL_OFFSET = 30; private static final int VERTICAL_OFFSET = 120; private static final double HEIGHT_PERCENTAGE = 0.8; private static final int PANEL_WIDTH = 750; - // Main UI components private JFrame frame; - - private ChatAreaPanel chatAreaPanel; // Contains the chat display and input area + private JSplitPane splitPane; private HeaderPanel headerPanel; - - private boolean processing; - - // Sidebar components - private JSplitPane splitPane; private SidebarPanel sidebarPanel; - - // Service layer that handles business logic and threading - private AIChatCoordinator coordinator; - - // Context-related UI components private ContextPanel contextPanel; + private ChatAreaPanel chatAreaPanel; + private JPanel tasksPanel; - private final ConversationManager conversationManager; - private final AIContextManager contextManager; - - private boolean isSwitchingChats = false; // Prevents the ContextChangeListener from overwriting saved data during chat switching + private final AIAssistantController controller; + private boolean processing; - // Singleton instance ensures only one floating panel exists at a time private static AIAssistantPanel instance; - public static AIAssistantPanel getInstance() { + /** + * Singleton instance ensures only one floating panel manager exists at a time. + */ + public static synchronized AIAssistantPanel getInstance() { if (instance == null) { instance = new AIAssistantPanel(); } @@ -82,477 +62,103 @@ public static AIAssistantPanel getInstance() { } /** - * Private constructor: This is the application's wiring hub. + * Private constructor initializing the Controller to achieve Inversion of Control (IoC). */ private AIAssistantPanel() { - this.conversationManager = ConversationManager.getInstance(); - this.contextManager = AIContextManager.getInstance(); - createUI(); - - // Subscribe to the State Manager. - // We use the Observer pattern to passively listen for context changes. - this.contextManager.addContextChangeListener(new ContextChangeListener() { - @Override - public void contextChanged(ContextChangeEvent event) { - // Abort if system is currently switching chats - if (isSwitchingChats) return; - - // Real-Time State Sync for Unsaved Drafts - Conversation activeConv = conversationManager.getActiveConversation(); - if (activeConv != null) { - - // Grab all valid file IDs currently sitting in the UI bucket - List currentIds = contextManager.getContextFiles().stream() - .map(IItem::getId) - .collect(Collectors.toList()); - - // Push them directly into the active conversation model - activeConv.setContextIds(currentIds); - activeConv.updateLastModified(); - - // Save the draft to disk asynchronously so it survives a sudden app crash - final Conversation convToSave = activeConv; - new Thread(() -> ConversationPersistence.saveConversation(convToSave)).start(); - } - } - }); - } - - public void startNewConversationWithCurrentContext(List pendingItems) { - Conversation newConversation = conversationManager.startNewConversation(); - - List contextIds = new ArrayList<>(); - for (IItem item : contextManager.getContextFiles()) { - if (item != null && !contextIds.contains(item.getId())) { - contextIds.add(item.getId()); - } - } - - if (pendingItems != null) { - for (IItem item : pendingItems) { - if (item != null && !contextIds.contains(item.getId())) { - contextIds.add(item.getId()); - } - } - } - - newConversation.setContextIds(contextIds); - newConversation.setChatHashes(new ArrayList<>()); - newConversation.setMessages(new ArrayList<>()); - newConversation.updateLastModified(); - - if (pendingItems != null) { - contextManager.addContextFiles(pendingItems); - } - - if (coordinator != null) { - coordinator.clearHistory(); - } - - sidebarPanel.refreshSidebarList(); - refreshChatArea(); - sidebarPanel.getConversationList().setSelectedValue(newConversation, true); - showFrame(); - } - - public boolean isProcessing() { - return processing; + this.controller = new AIAssistantController(this); + createBaseFrame(); + this.controller.initialize(); } - private void createUI() { + private void createBaseFrame() { String title = "AI Assistant"; - try { title = Messages.getString("AIAssistant.Title"); } catch (Exception e) {} + try { + title = Messages.getString("AIAssistant.Title"); + } catch (Exception e) {} frame = new JFrame(title); frame.setDefaultCloseOperation(WindowConstants.HIDE_ON_CLOSE); frame.setResizable(true); + } + + /** + * Assembles the layout using pre-configured passive components supplied by the controller. + */ + public void assembleLayout(HeaderPanel header, SidebarPanel sidebar, ContextPanel context, ChatAreaPanel chatArea, JPanel tasks) { + this.headerPanel = header; + this.sidebarPanel = sidebar; + this.contextPanel = context; + this.chatAreaPanel = chatArea; + this.tasksPanel = tasks; JPanel mainPanel = new JPanel(new BorderLayout(5, 5)); mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); - // Header with title and toggle sidebar button - headerPanel = new HeaderPanel(title, e -> toggleSidebar()); mainPanel.add(headerPanel, BorderLayout.NORTH); - // Chat Workspace JPanel chatWorkspacePanel = new JPanel(new BorderLayout(5, 5)); JPanel centerPanel = new JPanel(new BorderLayout(5, 5)); - contextPanel = new ContextPanel(contextManager); centerPanel.add(contextPanel, BorderLayout.NORTH); - - String sendText = "Send"; - try { sendText = Messages.getString("AIAssistant.Send"); } catch (Exception e) {} - - chatAreaPanel = new ChatAreaPanel(PANEL_WIDTH, sendText); - - chatAreaPanel.getSendButton().addActionListener(e -> handleSendAction()); - chatAreaPanel.getInputArea().addKeyListener(new KeyAdapter() { - @Override - public void keyPressed(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_ENTER && !e.isShiftDown()) { - e.consume(); - handleSendAction(); - } - } - }); - centerPanel.add(chatAreaPanel, BorderLayout.CENTER); - refreshChatArea(); - - chatAreaPanel.installTextPaneClickListener( - (hash, chunkId) -> navigateToItem(hash, chunkId), - this::refreshChatArea - ); - - JPanel tasksPanel = createTasksPanel(); centerPanel.add(tasksPanel, BorderLayout.EAST); chatWorkspacePanel.add(centerPanel, BorderLayout.CENTER); - // Conversations Sidebar - sidebarPanel = new SidebarPanel(frame, new SidebarPanel.SidebarListener() { - @Override - public void onConversationSelected(Conversation conversation) { - loadConversation(conversation); - } - - @Override - public void onNewChatRequested() { - startNewChat(); - } - - @Override - public void onConversationDeleted(Conversation conversation, boolean isActiveDeleted) { - if (isActiveDeleted) { - Conversation active = ConversationManager.getInstance().getActiveConversation(); - if (active != null) { - loadConversation(active); - } else { - clearChatScreenAndMemory(); - contextManager.clearContext(); - refreshChatArea(); - } - } - } - }, ConversationManager.getInstance()); - - // The SplitPane connecting them splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, sidebarPanel, chatWorkspacePanel); splitPane.setContinuousLayout(true); splitPane.setDividerSize(5); - splitPane.setBorder(null); // Keep it clean - splitPane.setDividerLocation(220); // Default sidebar width + splitPane.setBorder(null); + splitPane.setDividerLocation(220); mainPanel.add(splitPane, BorderLayout.CENTER); frame.getContentPane().add(mainPanel); frame.pack(); positionDialog(); - - addMessage("System", "AI Assistant ready. Connected to local Backend server.\nRight-click an HTML WhatsApp chat export to add it to the context, then type your question."); - - sidebarPanel.refreshSidebarList(); } - private void navigateToItem(String hash, String chunkId) { - if (hash == null || hash.isEmpty() || App.get() == null || App.get().appCase == null) { - return; - } - - new Thread(() -> { - try { - IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); - MultiSearchResult result = searcher.multiSearch(); - if (result == null || result.getLength() == 0) { - SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(frame, - "Item not found for hash: " + hash, "Not found", JOptionPane.INFORMATION_MESSAGE)); - return; - } - - IItemId itemId = result.getItem(0); - int luceneId = App.get().appCase.getLuceneId(itemId); - - // Prepare viewer to scroll to the specific message/chunk position - // chunkId corresponds to parentViewPosition in chat metadata - if (chunkId != null && !chunkId.isEmpty()) { - try { - App.get().getViewerController().getHtmlLinkViewer().setElementIDToScroll(chunkId); - } catch (Exception e) { - // Viewer might not be HTML or element not available; continue anyway - } - } - - FileProcessor fp = new FileProcessor(luceneId, true); - fp.execute(); - - // After opening the file, select it in the results table - SwingUtilities.invokeLater(() -> selectItemInResultsTable(luceneId)); - - } catch (Exception ex) { - ex.printStackTrace(); - SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(frame, - "Error opening item: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE)); - } - }).start(); - } - - private void selectItemInResultsTable(int luceneId) { - if (App.get() == null || App.get().getResults() == null || App.get().getResultsTable() == null) { - return; - } - - // Search for the item with the given luceneId in the results - for (int i = 0; i < App.get().getResults().getLength(); i++) { - try { - IItemId item = App.get().getResults().getItem(i); - if (App.get().appCase.getLuceneId(item) == luceneId) { - // Found it! Select this row in the results table - int viewIndex = App.get().getResultsTable().convertRowIndexToView(i); - App.get().getResultsTable().setRowSelectionInterval(viewIndex, viewIndex); - - // Scroll to make it visible - java.awt.Rectangle cellRect = App.get().getResultsTable().getCellRect(viewIndex, 0, false); - App.get().getResultsTable().scrollRectToVisible(cellRect); - break; - } - } catch (Exception e) { - // Continue searching if there's an error with this item - } - } - } - - private void toggleSidebar() { - if (sidebarPanel.isVisible()) { - sidebarPanel.setVisible(false); - splitPane.setDividerLocation(0); - splitPane.setDividerSize(0); - } else { - sidebarPanel.setVisible(true); - splitPane.setDividerSize(5); - splitPane.setDividerLocation(220); + /** + * Entry point for external IPED actions adding items to context. + * Delegates behavior directly to the controller. + */ + public void startNewConversationWithCurrentContext(List pendingItems) { + if (controller != null) { + controller.startNewConversationWithCurrentContext(pendingItems); } } - private void startNewChat() { - // Create a new active conversation in memory first (safeguards the old one) - ConversationManager.getInstance().startNewConversation(); - - // Clear UI and Coordinator memory - clearChatScreenAndMemory(); - - // Clear IPED context - contextManager.clearContext(); - - sidebarPanel.refreshSidebarList(); - refreshChatArea(); - - addMessage("System", "Started a new conversation session."); - chatAreaPanel.getInputArea().requestFocusInWindow(); + public boolean isProcessing() { + return processing; } /** - * Loads a selected conversation from the sidebar into the main chat window. + * Toggles the processing state visual cues across subcomponents. */ - private void loadConversation(Conversation conv) { - - // Lock the listener - isSwitchingChats = true; - - // Update State Manager - ConversationManager.getInstance().setActiveConversation(conv); - - // Hydrate the Coordinator's Network Memory - if (coordinator != null) { - coordinator.loadHistoricalContext(conv.getChatHashes(), conv.getContextIds(), conv.getMessages()); - } - - // Restore the visual IPED Context UI - contextManager.clearContext(); // Wipe previous chat's files - - new Thread(() -> { - List restoredItems = new ArrayList<>(); - - try { - // PATH A: The chat was previously sent to the backend - // Use the MD5 Chat Hashes to find the file - if (conv.getChatHashes() != null && !conv.getChatHashes().isEmpty()) { - for (String hash : conv.getChatHashes()) { - try { - IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); - MultiSearchResult result = searcher.multiSearch(); - - if (result != null && result.getLength() > 0) { - // Extract the fully qualified IItemId (which contains the source routing) - IItemId qualifiedItemId = result.getItem(0); - - // Now the MultiSource can safely fetch the item - IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); - if (item != null) { - restoredItems.add(item); - } - } - } catch (Exception e) { - System.err.println("Could not restore context item hash: " + hash); - } - } - } - // PATH B: The chat is an unsent draft (Fallback to internal IPED Context IDs) - else if (conv.getContextIds() != null && !conv.getContextIds().isEmpty()) { - for (Integer itemId : conv.getContextIds()) { - try { - IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "id:" + itemId); - MultiSearchResult result = searcher.multiSearch(); - - if (result != null && result.getLength() > 0) { - // Extract the fully qualified IItemId (which contains the source routing) - IItemId qualifiedItemId = result.getItem(0); - - // Now the MultiSource can safely fetch the item - IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); - if (item != null) { - restoredItems.add(item); - } - } - } catch (Exception e) { - System.err.println("Could not restore context item ID: " + itemId); - } - } - } - } finally { - SwingUtilities.invokeLater(() -> { - try { - if (!restoredItems.isEmpty()) { - // Check race condition: Did the user click a different chat while the search is ongoing? - Conversation currentActive = ConversationManager.getInstance().getActiveConversation(); - if (currentActive == null || !currentActive.getId().equals(conv.getId())) { - return; // Abort - } - - // Update the Coordinator's memory with the freshly fetched IDs just in case - // the database was rebuilt and the integer IDs changed - List freshIds = restoredItems.stream() - .map(IItem::getId) - .collect(Collectors.toList()); - - if (coordinator != null) { - // Re-sync the coordinator to prevent a false "contextChanged" flag - coordinator.loadHistoricalContext(conv.getChatHashes(), freshIds, conv.getMessages()); - } - - // Restore the visual UI - contextManager.addContextFiles(restoredItems); - } - } finally { - // Unlock the listener on the EDT - isSwitchingChats = false; - } - }); - } - }).start(); - - // Reset UI streaming states + public void setProcessing(boolean processing) { + this.processing = processing; if (chatAreaPanel != null) { - chatAreaPanel.forceDiscardStreaming(); + chatAreaPanel.setProcessing(processing); } - - // Redraw the screen - refreshChatArea(); - sidebarPanel.getConversationList().setSelectedValue(conv, true); + frame.setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) : Cursor.getDefaultCursor()); } - // Quick actions panel - private JPanel createTasksPanel() { - JPanel panel = new JPanel(); - panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); - panel.setBorder(BorderFactory.createTitledBorder("Quick Actions")); - - // Map English button text to Portuguese prompts - Map taskPrompts = new java.util.HashMap<>(); - taskPrompts.put("Summarize", "Resuma o arquivo fornecido."); - taskPrompts.put("Find Patterns", "Encontre padrões no arquivo fornecido."); - - String[] tasks = {"Summarize", "Find Patterns"}; - for (String task : tasks) { - JButton btn = new JButton(task); - btn.setAlignmentX(Component.CENTER_ALIGNMENT); - btn.setMaximumSize(new Dimension(200, 30)); - // Firing a pre-written prompt directly into the input area logic - btn.addActionListener(e -> { - chatAreaPanel.getInputArea().setText(taskPrompts.get(task)); - handleSendAction(); - }); - panel.add(btn); - panel.add(Box.createVerticalStrut(5)); - } - - return panel; - } - /** - * Clears the UI and the Coordinator's memory. - * Does NOT delete the saved messages in the ConversationManager. + * Performs visual shifting of the JSplitPane divider to hide or show the sidebar. */ - private void clearChatScreenAndMemory() { - // Wipe the Coordinator's memory so the LLM forgets the previous context - if (coordinator != null) { - coordinator.clearHistory(); - } - - if (chatAreaPanel != null) { - chatAreaPanel.clearChatScreen(); - } - } - - private void refreshChatArea() { - if (chatAreaPanel != null) { - chatAreaPanel.renderHistoricalMessages(buildRenderableMessages()); - } - } - - private boolean ensureChatServiceInitialized() { - if (coordinator != null) { - return true; - } - - try { - coordinator = new AIChatCoordinator(new AIBackendClient(AIBackendConfig.loadFromSystemProperties())); - return true; - } catch (Throwable t) { - addMessage("System Error", "Failed to initialize AI backend: " + t.getMessage(), "error"); - System.err.println("Failed to initialize AI backend: " + t.getMessage()); - t.printStackTrace(); - return false; - } - } - - private void addMessage(String sender, String message, String type) { - AIChatMessage chatMessage = AIChatMessage.now(sender, message, type); - - // Let the Manager hold the data - ConversationManager.getInstance().addMessageToActive(chatMessage); - - refreshChatArea(); - } - - private void addMessage(String sender, String message) { - addMessage(sender, message, "system"); - } - - // Renderable messages are the messages in the current active conversation - private List buildRenderableMessages() { - List renderableMessages = new ArrayList<>(); - - // Safely check if there is an active conversation - Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); - if (activeConv != null) { - renderableMessages.addAll(activeConv.getMessages()); + public void toggleSidebar() { + if (sidebarPanel == null || splitPane == null) { + return; } - - if (chatAreaPanel != null && chatAreaPanel.getCurrentDraftMessage() != null) { - renderableMessages.add(chatAreaPanel.getCurrentDraftMessage()); + if (sidebarPanel.isVisible()) { + sidebarPanel.setVisible(false); + splitPane.setDividerLocation(0); + splitPane.setDividerSize(0); + } else { + sidebarPanel.setVisible(true); + splitPane.setDividerSize(5); + splitPane.setDividerLocation(220); } - return renderableMessages; } private void positionDialog() { @@ -600,6 +206,9 @@ private void ensureVisibleOnScreen() { } } + /** + * Handles the asynchronous visibility initialization of the frame window. + */ public void showFrame() { Runnable action = () -> { ensureVisibleOnScreen(); @@ -628,70 +237,8 @@ public void showFrame() { } /** - * The main execution block linking user intent to the background Coordinator. + * Alternates the frame state between hidden and visible. */ - private void handleSendAction() { - String text = chatAreaPanel.getInputArea().getText().trim(); - if (!text.isEmpty()) { - if (!ensureChatServiceInitialized()) { - return; - } - - // If the user deleted all chats and types in the blank slate, start a new one - if (ConversationManager.getInstance().getActiveConversation() == null) { - ConversationManager.getInstance().startNewConversation(); - sidebarPanel.refreshSidebarList(); - } - - // Print user message immediately - addMessage("You", text, "user"); - chatAreaPanel.getInputArea().setText(""); - - // Lock the UI - setProcessing(true); - - AIChatMessage assistantDraft = AIChatMessage.now("Assistant", "", "assistant"); - - chatAreaPanel.startMessageStreaming(assistantDraft); - - // Call the service - coordinator.askQuestion( - text, - // Callback 1: Append tokens to the live assistant draft - (token) -> SwingUtilities.invokeLater(() -> { - chatAreaPanel.enqueueStreamingToken(token); - }), - // Callback 2: Keep the completed draft visible and unlock the UI - () -> SwingUtilities.invokeLater(() -> { - chatAreaPanel.pruneStreaming(() -> { - if (!assistantDraft.getContent().isEmpty()) { - // Registra a resposta final estável no modelo de dados histórico do IPED - ConversationManager.getInstance().addMessageToActive(assistantDraft); - sidebarPanel.refreshSidebarList(); - } - setProcessing(false); - }); - }), - // Callback 3: Handle Errors - (errorMessage) -> SwingUtilities.invokeLater(() -> { - chatAreaPanel.forceDiscardStreaming(); - addMessage("System Error", errorMessage, "error"); - setProcessing(false); - }) - ); - } - } - - /** - * Locks or unlocks the input fields and displays the loading bar. - */ - private void setProcessing(boolean processing) { - this.processing = processing; - chatAreaPanel.setProcessing(processing); - frame.setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) : Cursor.getDefaultCursor()); - } - - public void toggleVisibility() { SwingUtilities.invokeLater(() -> { if (frame.isVisible()) { @@ -701,4 +248,28 @@ public void toggleVisibility() { } }); } -} + + public JFrame getFrame() { + return frame; + } + + public HeaderPanel getHeaderPanel() { + return headerPanel; + } + + public SidebarPanel getSidebarPanel() { + return sidebarPanel; + } + + public ContextPanel getContextPanel() { + return contextPanel; + } + + public ChatAreaPanel getChatAreaPanel() { + return chatAreaPanel; + } + + public JPanel getTasksPanel() { + return tasksPanel; + } +} \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java index a7c19274c1..7555d4fc25 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java @@ -23,13 +23,12 @@ import javax.swing.SwingConstants; import javax.swing.border.TitledBorder; -import iped.app.ui.ai.context.AIContextManager; import iped.app.ui.ai.model.ContextFileEntry; import iped.data.IItem; /** - * Encapsulated component responsible for displaying and managing the interface - * for files added to the AI context. Applies SRP and reacts directly to the AIContextManager. + * Encapsulated component responsible for displaying the interface for files added to the AI context. + * Applies SRP and acts strictly as a Passive View, delegating all state changes to the ContextListener. */ public class ContextPanel extends JPanel { @@ -43,7 +42,16 @@ public class ContextPanel extends JPanel { private JButton clearContextButton; private TitledBorder contextBorder; - private final AIContextManager contextManager; + private final ContextListener listener; + + /** + * Contract for the ContextPanel's event listener, allowing external components (e.g., Controller) + * to intercept user interactions such as clearing the context or removing an individual file. + */ + public interface ContextListener { + void onClearContextRequested(); + void onRemoveFileRequested(IItem item); + } /** * Internal utility class responsible for rendering the capacity overflow line. @@ -61,13 +69,15 @@ public String toString() { } } - public ContextPanel(AIContextManager contextManager) { - this.contextManager = contextManager; + /** + * Constructs the ContextPanel as a passive UI component. + * @param listener The external controller listening to UI interaction events. + */ + public ContextPanel(ContextListener listener) { + this.listener = listener; configureLayout(); initComponents(); - setupContextListener(); - refreshContextUI(); } private void configureLayout() { @@ -101,7 +111,11 @@ private void initComponents() { clearContextButton = new JButton("Clear"); clearContextButton.setMargin(new Insets(0, 5, 0, 5)); clearContextButton.setEnabled(false); - clearContextButton.addActionListener(e -> contextManager.clearContext()); + clearContextButton.addActionListener(e -> { + if (listener != null) { + listener.onClearContextRequested(); + } + }); JPanel actionPanel = new JPanel(new BorderLayout()); actionPanel.add(clearContextButton, BorderLayout.NORTH); @@ -182,34 +196,38 @@ public void mouseClicked(MouseEvent e) { if (e.getX() < cellBounds.x + cellBounds.width - CONTEXT_REMOVE_HOTZONE_PX) return; - contextManager.removeContextFile(((ContextFileEntry) value).getItem()); + if (listener != null) { + listener.onRemoveFileRequested(((ContextFileEntry) value).getItem()); + } } }); } - private void setupContextListener() { - // The panel itself listens to its own state to redraw the list when the state changes - contextManager.addContextChangeListener(event -> refreshContextUI()); - } - /** - * Synchronizes the file UI with the actual domain state. + * Synchronizes the file UI components with the state data supplied by the controller. + * @param entries The reactive UI list entries representing current domain facts. */ - public void refreshContextUI() { - List entries = contextManager.getContextEntriesForUI(); - List validFiles = contextManager.getContextFiles(); - int invalidCount = entries.size() - validFiles.size(); + public void updateContextData(List entries) { contextListModel.clear(); - if (entries.isEmpty()) { + if (entries == null || entries.isEmpty()) { contextEmptyLabel.setVisible(true); contextList.setVisible(false); clearContextButton.setEnabled(false); + contextBorder.setTitle("Added Context (0 files)"); } else { contextEmptyLabel.setVisible(false); contextList.setVisible(true); clearContextButton.setEnabled(true); + int validCount = 0; + for (ContextFileEntry entry : entries) { + if (entry.isValidForContext()) { + validCount++; + } + } + int invalidCount = entries.size() - validCount; + int visibleCount = Math.min(CONTEXT_VISIBLE_ITEMS, entries.size()); for (int i = 0; i < visibleCount; i++) { contextListModel.addElement(entries.get(i)); @@ -217,15 +235,15 @@ public void refreshContextUI() { if (entries.size() > CONTEXT_VISIBLE_ITEMS) { int hiddenCount = entries.size() - CONTEXT_VISIBLE_ITEMS; - String summaryText = "+ " + hiddenCount + " more items (" + validFiles.size() + " valid, " + invalidCount + " rejected)"; + String summaryText = "+ " + hiddenCount + " more items (" + validCount + " valid, " + invalidCount + " rejected)"; contextListModel.addElement(new ContextSummaryRow(summaryText)); } - } - if (invalidCount > 0) { - contextBorder.setTitle("Added Context (" + validFiles.size() + " valid, " + invalidCount + " rejected)"); - } else { - contextBorder.setTitle("Added Context (" + validFiles.size() + " valid)"); + if (invalidCount > 0) { + contextBorder.setTitle("Added Context (" + validCount + " valid, " + invalidCount + " rejected)"); + } else { + contextBorder.setTitle("Added Context (" + validCount + " valid)"); + } } repaint(); diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java index b619c5ebe3..ab3715b003 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java @@ -21,11 +21,10 @@ import javax.swing.ListSelectionModel; import iped.app.ui.ai.model.Conversation; -import iped.app.ui.ai.context.ConversationManager; -import iped.app.ui.ai.util.ConversationPersistence; /** - * Sidebar component posponsable for displaying the list of conversations and allowing users to create, select, or delete conversations. + * Sidebar component responsible for displaying the list of conversations and allowing users to create, select, or delete conversations. + * Refactored to act strictly as a Passive View (MVP Pattern), delegating logic to the injected listener. */ public class SidebarPanel extends JPanel { @@ -36,26 +35,23 @@ public class SidebarPanel extends JPanel { private final Component parentFrame; private final SidebarListener listener; - private final ConversationManager conversationManager; - /** - * Contract for the SidebarPanel's event listener, allowing external components to react to user interactions + * Contract for the SidebarPanel's event listener, allowing external components (e.g., Controller) + * to react to user interactions without coupling the view to business logic or persistence. */ public interface SidebarListener { void onConversationSelected(Conversation conversation); void onNewChatRequested(); - void onConversationDeleted(Conversation conversation, boolean isActiveDeleted); + void onDeleteRequested(Conversation conversation); } /** * Constructs the SidebarPanel with necessary dependencies and initializes the UI components. - * * @param parentFrame Parent component used for dialog positioning. * @param listener External listener to handle sidebar events (selection, creation, deletion). */ - public SidebarPanel(Component parentFrame, SidebarListener listener, ConversationManager conversationManager) { + public SidebarPanel(Component parentFrame, SidebarListener listener) { this.parentFrame = parentFrame; - this.conversationManager = conversationManager; // dependency injection via constructor this.listener = listener; configurePanelLayout(); @@ -137,11 +133,8 @@ public void mouseClicked(MouseEvent e) { return; } - Conversation active = conversationManager.getActiveConversation(); - if (selected != null && (active == null || !active.getId().equals(selected.getId()))) { - if (listener != null) { - listener.onConversationSelected(selected); - } + if (selected != null && listener != null) { + listener.onConversationSelected(selected); } } }); @@ -156,12 +149,15 @@ public void setConversationList(JList conversationList) { } /** - * Sincronized method to refresh the conversation list UI based on the current state of ConversationManager. + * Updates the conversation list UI based on the provided list of conversations. + * @param conversations List of Conversation objects to display in the sidebar. */ - public void refreshSidebarList() { + public void updateConversationsList(List conversations) { conversationListModel.clear(); - for (Conversation conv : conversationManager.getConversations()) { - conversationListModel.addElement(conv); + if (conversations != null) { + for (Conversation conv : conversations) { + conversationListModel.addElement(conv); + } } conversationList.repaint(); } @@ -177,30 +173,8 @@ private void promptDeleteConversation(Conversation conv) { JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); - if (confirm == JOptionPane.YES_OPTION) { - // Removes logically and physically - ConversationPersistence.deleteConversation(conv.getId()); - conversationManager.removeConversation(conv); - - Conversation active = conversationManager.getActiveConversation(); - boolean isActiveDeleted = (active == null || active.getId().equals(conv.getId())); - - // Repositions the conversation pointer if the active conversation has been deleted - if (isActiveDeleted) { - List remaining = ConversationManager.getInstance().getConversations(); - if (!remaining.isEmpty()) { - conversationManager.setActiveConversation(remaining.get(0)); - } else { - conversationManager.setActiveConversation(null); - } - } - - // Notifies the external listener of side updates (e.g., clear screen) - if (listener != null) { - listener.onConversationDeleted(conv, isActiveDeleted); - } - - refreshSidebarList(); + if (confirm == JOptionPane.YES_OPTION && listener != null) { + listener.onDeleteRequested(conv); } } } \ No newline at end of file From 5a0e0f8d1e3ade01db9cc28f35a10055f0b16b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 21 May 2026 16:38:20 -0300 Subject: [PATCH 099/143] refactor: change comments to english --- .../java/iped/app/ui/ai/view/HeaderPanel.java | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/HeaderPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/HeaderPanel.java index 6f9595be74..7205141823 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/HeaderPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/HeaderPanel.java @@ -12,50 +12,52 @@ import javax.swing.JPanel; /** - * Painel modularizado para o cabeçalho da aplicação. - * Encapsula o título, botão de controle da sidebar e rótulo de status do backend. + * Modular panel for the application header. + * Encapsulates the title, sidebar toggle button, and backend status label. */ public class HeaderPanel extends JPanel { private final JLabel statusLabel; public HeaderPanel(String titleText, ActionListener toggleSidebarListener) { - // Inicializa o JPanel base com BorderLayout + + // Initialize the base JPanel with BorderLayout super(new BorderLayout()); - // Botão de alternância da barra lateral (Sidebar) + // Sidebar toggle button JButton toggleSidebarBtn = new JButton("☰"); toggleSidebarBtn.setMargin(new Insets(2, 6, 2, 6)); toggleSidebarBtn.setFocusPainted(false); toggleSidebarBtn.setToolTipText("Toggle Sidebar"); - // Executa a ação injetada pela classe principal + + // Execute the action injected by the main class toggleSidebarBtn.addActionListener(toggleSidebarListener); - // Rótulo do título + // Title label JLabel titleLabel = new JLabel(titleText); titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14)); - // Área do título (Botão + Texto) + // Title area (button + text) JPanel titleArea = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 0)); titleArea.add(toggleSidebarBtn); titleArea.add(titleLabel); - // Inicialização do rótulo de status + // Initialize status label statusLabel = new JLabel("● Connected to local backend server"); - statusLabel.setForeground(new Color(0, 150, 0)); // Verde para ativo + statusLabel.setForeground(new Color(0, 150, 0)); // Green for active - // Agrupamento do título e status no lado esquerdo + // Group title and status on the left side JPanel leftPanel = new JPanel(new BorderLayout(0, 5)); leftPanel.add(titleArea, BorderLayout.NORTH); leftPanel.add(statusLabel, BorderLayout.SOUTH); - // Adiciona os componentes ao painel principal (this) + // Add components to the main panel (this) add(leftPanel, BorderLayout.WEST); setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Color.LIGHT_GRAY)); } /** - * Permite que controladores externos atualizem o estado visual do status do backend. + * Allows external controllers to update the backend status visual state. */ public void updateStatus(String text, Color color) { statusLabel.setText(text); From 26266dbbc9149e8217e907bb0536282f06ea53fd Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Fri, 22 May 2026 16:12:05 -0300 Subject: [PATCH 100/143] refactor (minor): cleanup unused imports, rename chat model factory from `now` to `create`, and move title generation from Conversation to ConversationManager --- .../app/ui/ai/backend/AIBackendClient.java | 2 -- .../app/ui/ai/context/AIContextManager.java | 22 ++------------- .../ui/ai/context/ConversationManager.java | 28 ++++++++++++++++++- .../ai/controller/AIAssistantController.java | 4 +-- .../iped/app/ui/ai/model/AIChatMessage.java | 2 +- .../iped/app/ui/ai/model/Conversation.java | 15 ---------- .../iped/app/ui/ai/view/AIAssistantPanel.java | 1 - 7 files changed, 33 insertions(+), 41 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index 35b882d22f..5cc3717964 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -16,8 +16,6 @@ import java.util.function.Consumer; import java.util.List; import java.util.ArrayList; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Concrete implementation of {@link AIBackendService} responsible for handling diff --git a/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java index 9b8396508c..6c3f5e1e6f 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java @@ -11,8 +11,6 @@ import iped.app.ui.ai.util.SummaryValueExtractor; import iped.data.IItem; import iped.engine.lucene.analysis.CategoryTokenizer; -import iped.parsers.standard.StandardParser; -import iped.parsers.whatsapp.WhatsAppParser; import iped.properties.ExtraProperties; /** @@ -289,9 +287,9 @@ private Boolean readCommunicationIsEmpty(IItem item) { }; for (String key : keys) { - Boolean parsed = parseBooleanValue(readFirstValue(item, key)); - if (parsed != null) { - return parsed; + String value = readFirstValue(item, key); + if (value != null) { + return Boolean.parseBoolean(value.trim().toLowerCase()); } } return null; @@ -328,18 +326,4 @@ private String readFirstValue(IItem item, String key) { return null; } - - private Boolean parseBooleanValue(String raw) { - if (raw == null) { - return null; - } - String normalized = raw.trim().toLowerCase(); - if ("true".equals(normalized) || "1".equals(normalized)) { - return Boolean.TRUE; - } - if ("false".equals(normalized) || "0".equals(normalized)) { - return Boolean.FALSE; - } - return null; - } } \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java index 4ac2607979..e056291945 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java @@ -85,7 +85,7 @@ public void addMessageToActive(AIChatMessage message) { // If this is the first user message, generate the title if ("New Conversation".equals(activeConversation.getTitle()) && "user".equals(message.getType())) { - activeConversation.autoGenerateTitle(); + autoGenerateTitle(activeConversation); } // Save to disk asynchronously @@ -93,4 +93,30 @@ public void addMessageToActive(AIChatMessage message) { new Thread(() -> ConversationPersistence.saveConversation(convToSave)).start(); } } + + /** + * Auto-generates a title based on the first user message if the title is default + */ + public void autoGenerateTitle(Conversation conversation) { + if (conversation == null) return; + + if (!"New Conversation".equals(conversation.getTitle())) { + return; + } + + for (AIChatMessage msg : conversation.getMessages()) { + if ("user".equals(msg.getType())) { + String content = msg.getContent(); + + if (content != null && !content.isBlank()) { + String title = content.length() > 30 + ? content.substring(0, 27) + "..." + : content; + + conversation.setTitle(title); + } + break; + } + } + } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java index 0b6bb2e243..ec4e4fed3e 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -360,7 +360,7 @@ private void handleSendAction() { chatAreaView.getInputArea().setText(""); mainView.setProcessing(true); - AIChatMessage assistantDraft = AIChatMessage.now("Assistant", "", "assistant"); + AIChatMessage assistantDraft = AIChatMessage.create("Assistant", "", "assistant"); chatAreaView.startMessageStreaming(assistantDraft); coordinator.askQuestion( @@ -384,7 +384,7 @@ private void handleSendAction() { } private void addMessage(String sender, String message, String type) { - AIChatMessage chatMessage = AIChatMessage.now(sender, message, type); + AIChatMessage chatMessage = AIChatMessage.create(sender, message, type); conversationManager.addMessageToActive(chatMessage); refreshChatArea(); } diff --git a/iped-app/src/main/java/iped/app/ui/ai/model/AIChatMessage.java b/iped-app/src/main/java/iped/app/ui/ai/model/AIChatMessage.java index 4794f4e273..41caa12181 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/model/AIChatMessage.java +++ b/iped-app/src/main/java/iped/app/ui/ai/model/AIChatMessage.java @@ -20,7 +20,7 @@ private AIChatMessage(String sender, String content, String type, String time) { this.time = time; } - public static AIChatMessage now(String sender, String content, String type) { + public static AIChatMessage create(String sender, String content, String type) { String time = new SimpleDateFormat("HH:mm").format(new Date()); return new AIChatMessage(sender, content, type, time); } diff --git a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java index 24463dc1ea..39375319ab 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java +++ b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java @@ -61,19 +61,4 @@ public boolean hasAssistantReply() { } return false; } - - /** - * Auto-generates a title based on the first user message if the title is default - */ - public void autoGenerateTitle() { - if ("New Conversation".equals(this.title) && !messages.isEmpty()) { - for (AIChatMessage msg : messages) { - if ("user".equals(msg.getType())) { - String content = msg.getContent(); - this.title = content.length() > 30 ? content.substring(0, 27) + "..." : content; - break; - } - } - } - } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index ef023f54f6..afb4fcda3d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -1,7 +1,6 @@ package iped.app.ui.ai.view; import java.awt.BorderLayout; -import java.awt.Component; import java.awt.Cursor; import java.awt.GraphicsConfiguration; import java.awt.GraphicsDevice; From 9884d5109605627164051ef9819162c9e8071ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Fri, 22 May 2026 16:26:12 -0300 Subject: [PATCH 101/143] refactor: implement context locking mechanism to prevent modifications after llm response --- .../ai/controller/AIAssistantController.java | 12 ++++++ .../iped/app/ui/ai/view/ContextPanel.java | 38 ++++++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java index 0b6bb2e243..df2fb7d060 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -393,13 +393,25 @@ private void refreshChatArea() { if (chatAreaView != null) { List renderableMessages = new ArrayList<>(); Conversation activeConv = conversationManager.getActiveConversation(); + + boolean hasMessages = false; + if (activeConv != null) { renderableMessages.addAll(activeConv.getMessages()); + hasMessages = activeConv.hasAssistantReply(); } + if (chatAreaView.getCurrentDraftMessage() != null) { renderableMessages.add(chatAreaView.getCurrentDraftMessage()); + hasMessages = true; // if there's a draft we already lock } + chatAreaView.renderHistoricalMessages(renderableMessages); + + // Bussines rule: blocks context editing if assistant has replied or there's a draft + if (contextView != null) { + contextView.setLocked(hasMessages); + } } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java index 7555d4fc25..966c7bdd42 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java @@ -43,6 +43,7 @@ public class ContextPanel extends JPanel { private TitledBorder contextBorder; private final ContextListener listener; + private boolean isLocked = false; /** * Contract for the ContextPanel's event listener, allowing external components (e.g., Controller) @@ -112,7 +113,7 @@ private void initComponents() { clearContextButton.setMargin(new Insets(0, 5, 0, 5)); clearContextButton.setEnabled(false); clearContextButton.addActionListener(e -> { - if (listener != null) { + if (!isLocked && listener != null) { listener.onClearContextRequested(); } }); @@ -124,6 +125,27 @@ private void initComponents() { add(actionPanel, BorderLayout.EAST); } + /** + * Blocks or unblocks the context modification actions based on the conversation state. + * When locked, the user cannot add or remove files from the context, and the UI reflects this state visually. + */ + public void setLocked(boolean locked) { + this.isLocked = locked; + clearContextButton.setEnabled(!locked && contextListModel.getSize() > 0); + + String currentTitle = contextBorder.getTitle(); + if (currentTitle != null) { + if (locked && !currentTitle.endsWith("[LOCKED]")) { + contextBorder.setTitle(currentTitle + " [LOCKED]"); + } else if (!locked && currentTitle.endsWith(" [LOCKED]")) { + contextBorder.setTitle(currentTitle.replace(" [LOCKED]", "")); + } + } + + contextList.repaint(); + repaint(); + } + private void setupCellRenderer() { contextList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { if (value instanceof ContextSummaryRow) { @@ -159,7 +181,8 @@ private JComponent createContextEntryCell(JList list, ContextFileEntry entry, textLabel.setFont(list.getFont()); textLabel.setForeground(isSelected ? list.getSelectionForeground() : list.getForeground()); - JLabel removeLabel = new JLabel("X"); + // Removes "X" if the panel is locked + JLabel removeLabel = new JLabel(isLocked ? "" : "X"); removeLabel.setOpaque(false); removeLabel.setFont(list.getFont().deriveFont(Font.BOLD)); removeLabel.setForeground(new Color(160, 0, 0)); @@ -183,6 +206,9 @@ private void setupMouseListener() { contextList.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { + // Ignores clicks when the panel is locked + if (isLocked) return; + if (e.getButton() != MouseEvent.BUTTON1) return; int index = contextList.locationToIndex(e.getPoint()); @@ -218,7 +244,8 @@ public void updateContextData(List entries) { } else { contextEmptyLabel.setVisible(false); contextList.setVisible(true); - clearContextButton.setEnabled(true); + // deactivate clear button if locked + clearContextButton.setEnabled(!isLocked); int validCount = 0; for (ContextFileEntry entry : entries) { @@ -239,10 +266,11 @@ public void updateContextData(List entries) { contextListModel.addElement(new ContextSummaryRow(summaryText)); } + String lockText = isLocked ? " [LOCKED]" : ""; if (invalidCount > 0) { - contextBorder.setTitle("Added Context (" + validCount + " valid, " + invalidCount + " rejected)"); + contextBorder.setTitle("Added Context (" + validCount + " valid, " + invalidCount + " rejected)" + lockText); } else { - contextBorder.setTitle("Added Context (" + validCount + " valid)"); + contextBorder.setTitle("Added Context (" + validCount + " valid)" + lockText); } } From ce518b53bed8008c68c5312f42eb8360396e0f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Fri, 22 May 2026 16:26:25 -0300 Subject: [PATCH 102/143] refactor: implement glass pane for global blocking and UI locking during processing --- .../iped/app/ui/ai/view/AIAssistantPanel.java | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index ef023f54f6..5e6b661b18 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -6,8 +6,12 @@ import java.awt.GraphicsConfiguration; import java.awt.GraphicsDevice; import java.awt.GraphicsEnvironment; +import java.awt.Point; import java.awt.Rectangle; import java.awt.Window; +import java.awt.event.KeyAdapter; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseMotionAdapter; import java.util.List; import javax.swing.JFrame; @@ -79,6 +83,31 @@ private void createBaseFrame() { frame = new JFrame(title); frame.setDefaultCloseOperation(WindowConstants.HIDE_ON_CLOSE); frame.setResizable(true); + + // --- BEGIN GLOBAL BLOCKING WITH A "HOLE" (GLASS PANE) --- + JPanel glassPane = new JPanel() { + @Override + public boolean contains(int x, int y) { + // If HeaderPanel exists, check whether the click lands in its area + if (headerPanel != null && headerPanel.isVisible()) { + // Convert glass pane coordinates to HeaderPanel coordinates + Point p = SwingUtilities.convertPoint(this, x, y, headerPanel); + // If HeaderPanel contains this point, return false (click passes through) + if (headerPanel.contains(p)) { + return false; + } + } + // For the rest of the screen, the glass pane is solid and blocks clicks + return super.contains(x, y); + } + }; + glassPane.setOpaque(false); + glassPane.addMouseListener(new MouseAdapter() {}); + glassPane.addMouseMotionListener(new MouseMotionAdapter() {}); + glassPane.addKeyListener(new KeyAdapter() {}); + glassPane.setFocusTraversalKeysEnabled(false); + frame.setGlassPane(glassPane); + // --- END GLOBAL BLOCKING --- } /** @@ -133,13 +162,27 @@ public boolean isProcessing() { } /** - * Toggles the processing state visual cues across subcomponents. + * Toggles the processing state visual cues across subcomponents and locks the UI. */ public void setProcessing(boolean processing) { this.processing = processing; if (chatAreaPanel != null) { chatAreaPanel.setProcessing(processing); } + + // Enable or disable the transparent shield + frame.getGlassPane().setVisible(processing); + + if (processing) { + // Steal focus for the shield to prevent keyboard interaction with buttons + frame.getGlassPane().requestFocusInWindow(); + } else { + // When the AI finishes responding, restore focus to the text input + if (chatAreaPanel != null) { + chatAreaPanel.requestFocusToInput(); + } + } + frame.setCursor(processing ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) : Cursor.getDefaultCursor()); } From 88d368f11603c4aeb906644b025ac16aacc58efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Fri, 22 May 2026 17:00:45 -0300 Subject: [PATCH 103/143] refactor: fix breaking tittles --- .../ai/controller/AIAssistantController.java | 13 ++++++++- .../iped/app/ui/ai/view/ContextPanel.java | 27 ++++++++++--------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java index ac2e173bfd..dabd001b08 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -13,8 +13,10 @@ import javax.swing.Box; import javax.swing.BoxLayout; import javax.swing.JButton; +import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; +import javax.swing.SwingConstants; import javax.swing.SwingUtilities; import iped.app.ui.App; @@ -479,7 +481,16 @@ private void selectItemInResultsTable(int luceneId) { private JPanel createTasksPanel() { JPanel panel = new JPanel(); panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); - panel.setBorder(BorderFactory.createTitledBorder("Quick Actions")); + panel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createTitledBorder(BorderFactory.createEtchedBorder(), ""), + BorderFactory.createEmptyBorder(8, 8, 8, 8))); + + JLabel titleLabel = new JLabel("Quick Actions"); + titleLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + titleLabel.setHorizontalAlignment(SwingConstants.CENTER); + titleLabel.setFont(titleLabel.getFont().deriveFont(java.awt.Font.BOLD)); + panel.add(titleLabel); + panel.add(Box.createVerticalStrut(6)); Map taskPrompts = new java.util.HashMap<>(); taskPrompts.put("Summarize", "Resuma o arquivo fornecido."); diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java index 966c7bdd42..11be0e0b30 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java @@ -21,7 +21,6 @@ import javax.swing.JScrollPane; import javax.swing.ListSelectionModel; import javax.swing.SwingConstants; -import javax.swing.border.TitledBorder; import iped.app.ui.ai.model.ContextFileEntry; import iped.data.IItem; @@ -38,9 +37,9 @@ public class ContextPanel extends JPanel { private JList contextList; private DefaultListModel contextListModel; + private JLabel contextTitleLabel; private JLabel contextEmptyLabel; private JButton clearContextButton; - private TitledBorder contextBorder; private final ContextListener listener; private boolean isLocked = false; @@ -83,8 +82,12 @@ public ContextPanel(ContextListener listener) { private void configureLayout() { setLayout(new BorderLayout(5, 5)); - contextBorder = BorderFactory.createTitledBorder("Added Context (0 files)"); - setBorder(contextBorder); + setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createTitledBorder(BorderFactory.createEtchedBorder(), ""), + BorderFactory.createEmptyBorder(8, 8, 8, 8))); + contextTitleLabel = new JLabel("Added Context (0 files)"); + contextTitleLabel.setFont(contextTitleLabel.getFont().deriveFont(Font.BOLD)); + add(contextTitleLabel, BorderLayout.NORTH); } private void initComponents() { @@ -133,13 +136,11 @@ public void setLocked(boolean locked) { this.isLocked = locked; clearContextButton.setEnabled(!locked && contextListModel.getSize() > 0); - String currentTitle = contextBorder.getTitle(); + String currentTitle = contextTitleLabel.getText(); if (currentTitle != null) { - if (locked && !currentTitle.endsWith("[LOCKED]")) { - contextBorder.setTitle(currentTitle + " [LOCKED]"); - } else if (!locked && currentTitle.endsWith(" [LOCKED]")) { - contextBorder.setTitle(currentTitle.replace(" [LOCKED]", "")); - } + String baseTitle = currentTitle.replace(" [LOCKED]", ""); + String lockText = locked ? " [LOCKED]" : ""; + contextTitleLabel.setText(baseTitle + lockText); } contextList.repaint(); @@ -240,7 +241,7 @@ public void updateContextData(List entries) { contextEmptyLabel.setVisible(true); contextList.setVisible(false); clearContextButton.setEnabled(false); - contextBorder.setTitle("Added Context (0 files)"); + contextTitleLabel.setText("Added Context (0 files)"); } else { contextEmptyLabel.setVisible(false); contextList.setVisible(true); @@ -268,9 +269,9 @@ public void updateContextData(List entries) { String lockText = isLocked ? " [LOCKED]" : ""; if (invalidCount > 0) { - contextBorder.setTitle("Added Context (" + validCount + " valid, " + invalidCount + " rejected)" + lockText); + contextTitleLabel.setText("Added Context (" + validCount + " valid, " + invalidCount + " rejected)" + lockText); } else { - contextBorder.setTitle("Added Context (" + validCount + " valid)" + lockText); + contextTitleLabel.setText("Added Context (" + validCount + " valid)" + lockText); } } From d935b3688b98b0efcbb80cc6fe192d9e75bdf498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Fri, 22 May 2026 17:28:27 -0300 Subject: [PATCH 104/143] refactor: replace AIWhatsappChatExtractor with ContextItemValidator for context validation and change method name --- .../app/ui/ai/context/AIContextManager.java | 113 +--------------- .../ui/ai/util/AIWhatsappChatExtractor.java | 15 ++- .../app/ui/ai/util/ContextItemValidator.java | 123 ++++++++++++++++++ 3 files changed, 134 insertions(+), 117 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/util/ContextItemValidator.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java index 6c3f5e1e6f..4f05a5ef5a 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java @@ -7,11 +7,8 @@ import javax.swing.event.EventListenerList; import iped.app.ui.ai.model.ContextFileEntry; -import iped.app.ui.ai.util.AIWhatsappChatExtractor; -import iped.app.ui.ai.util.SummaryValueExtractor; +import iped.app.ui.ai.util.ContextItemValidator; import iped.data.IItem; -import iped.engine.lucene.analysis.CategoryTokenizer; -import iped.properties.ExtraProperties; /** * Singleton manager responsible for maintaining the AI context file list. @@ -44,7 +41,7 @@ public class AIContextManager { /** Listener list for context change events */ private final EventListenerList listeners; - private AIWhatsappChatExtractor chatExtractor = new AIWhatsappChatExtractor(); + private final ContextItemValidator validator = new ContextItemValidator(); /** * Private constructor to enforce singleton pattern. @@ -193,7 +190,7 @@ public void addContextFiles(List items) { continue; } - String rejectionReason = getRejectionReason(item); + String rejectionReason = validator.getRejectionReason(item); if (rejectionReason != null) { if (invalidEntries.removeIf(entry -> entry.getItem().getId() == item.getId())) { changedAny = true; @@ -222,108 +219,4 @@ public void addContextFiles(List items) { } } - private String getRejectionReason(IItem item) { - if (!chatExtractor.isPotentiallyValidChat(item)) { - return "Rejected: Not a WhatsApp chat item."; - } - - if (hasEmptyFilesCategory(item)) { - return "Rejected: Category is Empty Files."; - } - - if (SummaryValueExtractor.hasSummary(item)) { - return null; - } - - Boolean isEmpty = readCommunicationIsEmpty(item); - if (Boolean.TRUE.equals(isEmpty)) { - return "Rejected: Communication is empty."; - } - return null; - } - - private boolean hasEmptyFilesCategory(IItem item) { - if (item == null) { - return false; - } - - if (item.getCategorySet() != null) { - for (String category : item.getCategorySet()) { - if (isEmptyFilesCategoryValue(category)) { - return true; - } - } - } - - String categories = item.getCategories(); - if (categories != null && !categories.isBlank()) { - String[] splitCategories = categories.split(String.valueOf(CategoryTokenizer.SEPARATOR)); - for (String category : splitCategories) { - if (isEmptyFilesCategoryValue(category)) { - return true; - } - } - } - - return false; - } - - private boolean isEmptyFilesCategoryValue(String value) { - if (value == null) { - return false; - } - - String normalized = value.trim().toLowerCase(); - return normalized.equals("empty files") || normalized.contains("empty files"); - } - - private Boolean readCommunicationIsEmpty(IItem item) { - if (item == null) { - return null; - } - - String[] keys = { - ExtraProperties.COMMUNICATION_PREFIX + "isEmpty" - }; - - for (String key : keys) { - String value = readFirstValue(item, key); - if (value != null) { - return Boolean.parseBoolean(value.trim().toLowerCase()); - } - } - return null; - } - - private String readFirstValue(IItem item, String key) { - Object extra = item.getExtraAttribute(key); - if (extra != null) { - if (extra instanceof String) { - return (String) extra; - } - if (extra instanceof Boolean) { - return String.valueOf(extra); - } - if (extra instanceof String[] && ((String[]) extra).length > 0) { - return ((String[]) extra)[0]; - } - return String.valueOf(extra); - } - - if (item.getMetadata() == null) { - return null; - } - - String value = item.getMetadata().get(key); - if (value != null) { - return value; - } - - String[] values = item.getMetadata().getValues(key); - if (values != null && values.length > 0) { - return values[0]; - } - - return null; - } } \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java b/iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java index 0399c70c4a..322471237b 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java +++ b/iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java @@ -20,23 +20,24 @@ public class AIWhatsappChatExtractor { /** *

- * Basic validation to check if the item is an HTML file. + * Basic validation to check whether the item is a WhatsApp chat by content type. * Handles standard files and virtual files extracted from databases (which may lack extensions). *

* @param item The IPED evidence item to inspect. - * @return true if the file indicates HTML or WhatsApp chat content; false otherwise. + * @return true if the file indicates WhatsApp chat content; false otherwise. */ - public boolean isPotentiallyValidChat(IItem item) { + public boolean isWhatsAppChatType(IItem item) { if (item == null) { return false; } - String chatContentType = WhatsAppParser.WHATSAPP_CHAT.toString(); - if (item.getMediaType() != null && chatContentType.equals(item.getMediaType().toString())) { + String whatsAppChatContentType = WhatsAppParser.WHATSAPP_CHAT.toString(); + if (item.getMediaType() != null && whatsAppChatContentType.equals(item.getMediaType().toString())) { return true; } - return item.getMetadata() != null && chatContentType.equals(item.getMetadata().get(StandardParser.INDEXER_CONTENT_TYPE)); + return item.getMetadata() != null + && whatsAppChatContentType.equals(item.getMetadata().get(StandardParser.INDEXER_CONTENT_TYPE)); } /** @@ -48,7 +49,7 @@ public boolean isPotentiallyValidChat(IItem item) { * @throws Exception if reading the stream fails due to an I/O error. */ public String extractHtml(IItem item) throws Exception { - if (!isPotentiallyValidChat(item)) { + if (!isWhatsAppChatType(item)) { throw new IllegalArgumentException("Selected file does not appear to be a WhatsApp chat export."); } diff --git a/iped-app/src/main/java/iped/app/ui/ai/util/ContextItemValidator.java b/iped-app/src/main/java/iped/app/ui/ai/util/ContextItemValidator.java new file mode 100644 index 0000000000..1ce5c0ac64 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/util/ContextItemValidator.java @@ -0,0 +1,123 @@ +package iped.app.ui.ai.util; + +import iped.data.IItem; +import iped.engine.lucene.analysis.CategoryTokenizer; +import iped.properties.ExtraProperties; + +public class ContextItemValidator { + + private final AIWhatsappChatExtractor chatExtractor; + + public ContextItemValidator() { + this(new AIWhatsappChatExtractor()); + } + + public ContextItemValidator(AIWhatsappChatExtractor chatExtractor) { + this.chatExtractor = chatExtractor; + } + + public String getRejectionReason(IItem item) { + if (!chatExtractor.isWhatsAppChatType(item)) { + return "Rejected: Not a WhatsApp chat item."; + } + + if (hasEmptyFilesCategory(item)) { + return "Rejected: Category is Empty Files."; + } + + if (SummaryValueExtractor.hasSummary(item)) { + return null; + } + + Boolean isEmpty = readCommunicationIsEmpty(item); + if (Boolean.TRUE.equals(isEmpty)) { + return "Rejected: Communication is empty."; + } + return null; + } + + private boolean hasEmptyFilesCategory(IItem item) { + if (item == null) { + return false; + } + + if (item.getCategorySet() != null) { + for (String category : item.getCategorySet()) { + if (isEmptyFilesCategoryValue(category)) { + return true; + } + } + } + + String categories = item.getCategories(); + if (categories != null && !categories.isBlank()) { + String[] splitCategories = categories.split(String.valueOf(CategoryTokenizer.SEPARATOR)); + for (String category : splitCategories) { + if (isEmptyFilesCategoryValue(category)) { + return true; + } + } + } + + return false; + } + + private boolean isEmptyFilesCategoryValue(String value) { + if (value == null) { + return false; + } + + String normalized = value.trim().toLowerCase(); + return normalized.equals("empty files") || normalized.contains("empty files"); + } + + private Boolean readCommunicationIsEmpty(IItem item) { + if (item == null) { + return null; + } + + String[] keys = { + ExtraProperties.COMMUNICATION_PREFIX + "isEmpty" + }; + + for (String key : keys) { + String value = readFirstValue(item, key); + if (value != null) { + return Boolean.parseBoolean(value.trim().toLowerCase()); + } + } + return null; + } + + private String readFirstValue(IItem item, String key) { + Object extra = item.getExtraAttribute(key); + if (extra != null) { + if (extra instanceof String) { + return (String) extra; + } + if (extra instanceof Boolean) { + return String.valueOf(extra); + } + if (extra instanceof String[] && ((String[]) extra).length > 0) { + return ((String[]) extra)[0]; + } + return String.valueOf(extra); + } + + if (item.getMetadata() == null) { + return null; + } + + String value = item.getMetadata().get(key); + if (value != null) { + return value; + } + + String[] values = item.getMetadata().getValues(key); + if (values != null && values.length > 0) { + return values[0]; + } + + return null; + } +} From c8b1e089ebb1f0d9cd7d08e5db52b4c62f374cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Fri, 22 May 2026 17:58:10 -0300 Subject: [PATCH 105/143] refactor: organizing --- .../java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java b/iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java index 322471237b..6ea31a4123 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java +++ b/iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java @@ -18,6 +18,8 @@ */ public class AIWhatsappChatExtractor { + private final String whatsAppChatContentType = WhatsAppParser.WHATSAPP_CHAT.toString(); + /** *

* Basic validation to check whether the item is a WhatsApp chat by content type. @@ -31,7 +33,6 @@ public boolean isWhatsAppChatType(IItem item) { return false; } - String whatsAppChatContentType = WhatsAppParser.WHATSAPP_CHAT.toString(); if (item.getMediaType() != null && whatsAppChatContentType.equals(item.getMediaType().toString())) { return true; } @@ -44,7 +45,7 @@ public boolean isWhatsAppChatType(IItem item) { * Extracts the raw file content from the IItem into a UTF-8 encoded String. * @param item The IPED evidence item containing the chat log. * @return The complete, raw HTML string. - * @throws IllegalArgumentException if the item fails basic HTML validation. + * @throws IllegalArgumentException if the item is null. * @throws IllegalStateException if the underlying file stream cannot be opened. * @throws Exception if reading the stream fails due to an I/O error. */ @@ -68,8 +69,6 @@ public String extractHtml(IItem item) throws Exception { buffer.write(data, 0, nRead); } - // WhatsApp HTML exports heavily utilize emojis and international characters. - // Forcing UTF-8 is strictly required here. return new String(buffer.toByteArray(), StandardCharsets.UTF_8); } } From c64bb0744a3609323cd12ad8812d860cd624031c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Mon, 25 May 2026 14:12:21 -0300 Subject: [PATCH 106/143] feat: changes the delete conversation logic, sets a flag "deleted" insted of deleting the file --- .../ai/controller/AIAssistantController.java | 2 +- .../iped/app/ui/ai/model/Conversation.java | 7 ++++++- .../ui/ai/util/ConversationPersistence.java | 18 ++++++++---------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java index ac2e173bfd..9c898d531d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -213,7 +213,7 @@ private void startNewChat() { } private void deleteChat(Conversation conv) { - ConversationPersistence.deleteConversation(conv.getId()); + ConversationPersistence.deleteConversation(conv); conversationManager.removeConversation(conv); Conversation active = conversationManager.getActiveConversation(); diff --git a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java index 39375319ab..15300535fd 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java +++ b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java @@ -7,6 +7,7 @@ public class Conversation { private String id; private String title; + private String status; // e.g. "active", "deleted" private long createdAt; private long lastModified; private List contextIds; @@ -15,18 +16,22 @@ public class Conversation { public Conversation() { this.id = UUID.randomUUID().toString(); // Universally Unique Identifier + this.title = "New Conversation"; + this.status = "active"; this.createdAt = System.currentTimeMillis(); this.lastModified = this.createdAt; this.contextIds = new ArrayList<>(); this.chatHashes = new ArrayList<>(); this.messages = new ArrayList<>(); - this.title = "New Conversation"; } // Standard Getters and Setters public String getId() { return id; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } public long getCreatedAt() { return createdAt; } public long getLastModified() { return lastModified; } diff --git a/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java b/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java index 84db5d9ad2..0bc3d194a8 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java +++ b/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java @@ -99,7 +99,9 @@ public static List loadAllConversations() { try (FileReader reader = new FileReader(file)) { Conversation conv = GSON.fromJson(reader, Conversation.class); if (conv != null) { - conversations.add(conv); + if (!"deleted".equals(conv.getStatus())) { + conversations.add(conv); + } } } catch (Exception e) { System.err.println("Failed to load AI conversation file " + file.getName() + ": " + e.getMessage()); @@ -113,15 +115,11 @@ public static List loadAllConversations() { } /** - * Deletes the JSON file associated with the given conversation ID. + * Flags the conversation as deleted by removing, but does not permanently delete the file */ - public static void deleteConversation(String conversationId) { - File dir = getStorageDirectory(); - if (dir == null || conversationId == null) return; - - File chatFile = new File(dir, "chat_" + conversationId + ".json"); - if (chatFile.exists()) { - chatFile.delete(); - } + public static void deleteConversation(Conversation conversation) { + if (conversation == null) return; + conversation.setStatus("deleted"); + saveConversation(conversation); } } \ No newline at end of file From 32019474924ef011d3091c1b9ef7bc795b991953 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 25 May 2026 14:17:23 -0300 Subject: [PATCH 107/143] fix(ai-coordinator): preserve cache hashes if backend fails during streaming - Add `initializationCompleted` flag to `askQuestion()` pipeline. - Prevent `currentChatHashes` and `currentContextItemIds` from clearing if the error occurs after Stage A (Initialization). - Allows users to retry a failed prompt without triggering a redundant context re-upload. --- .../java/iped/app/ui/ai/AIChatCoordinator.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index f4011b1b85..502a9873ce 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -82,6 +82,10 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o // Offload heavy lifting to a background thread new Thread(() -> { + + // Flag used to discern whether the hashes and context ids need clearing + boolean initializationCompleted = !needsInitialization; + try { // Step A: Initialize the Chat if (needsInitialization) { @@ -115,6 +119,9 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o // Save the hydrated object to disk ConversationPersistence.saveConversation(activeConv); } + + // Update the flag + initializationCompleted = true; } // Step B: Stream the response @@ -145,9 +152,13 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o } catch (Exception e) { onError.accept("Backend error: " + e.getMessage()); - // Invalidate the cache on error so the next attempt tries a fresh upload - currentContextItemIds.clear(); - currentChatHashes.clear(); + + // Only invalidate cache if initialization itself failed + // If error happened during streaming, preserve the hashes so the user can retry + if (!initializationCompleted) { + currentContextItemIds.clear(); + currentChatHashes.clear(); + } } finally { onComplete.run(); } From 113e6ea55d8d69fc42b85ab3e9b750aae5b2cf03 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 26 May 2026 17:02:52 -0300 Subject: [PATCH 108/143] fix(handleSendAction): preserve partial streaming draft before reporting backend error --- .../ui/ai/controller/AIAssistantController.java | 8 +++++++- .../java/iped/app/ui/ai/view/ChatAreaPanel.java | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java index b311340179..0d96d87933 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -378,7 +378,13 @@ private void handleSendAction() { }); }), (errorMessage) -> SwingUtilities.invokeLater(() -> { - chatAreaView.forceDiscardStreaming(); + AIChatMessage partialDraft = chatAreaView.salvageStreamingDraft(); + + if (partialDraft != null && !partialDraft.getContent().isEmpty()) { + conversationManager.addMessageToActive(partialDraft); + sidebarView.updateConversationsList(conversationManager.getConversations()); + } + addMessage("System Error", errorMessage, "error"); mainView.setProcessing(false); }) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java index 9706462806..133cf84f54 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java @@ -261,4 +261,19 @@ public void mouseClicked(MouseEvent e) { } }); } + + public AIChatMessage salvageStreamingDraft() { + streamAnimator.resetState(); + + AIChatMessage salvaged = currentDraftMessage; + if (salvaged != null && salvaged.getContent() != null && !salvaged.getContent().isEmpty()) { + markdownRenderer.commitDraft(); + } else { + markdownRenderer.discardDraft(); + salvaged = null; + } + + currentDraftMessage = null; + return salvaged; + } } From 2b39cc3d426ab7e9b31c9f5e6603e30fa4719c0e Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Wed, 27 May 2026 14:09:36 -0300 Subject: [PATCH 109/143] refactor: rename context lock to context-edit lock and clarify auto-fork behavior The previous "locked context" wording was overly broad and implied that context mutation was globally disabled after an assistant reply. This change clarifies the actual behavior: - only direct context editing in the ContextPanel UI is disabled once a conversation has an assistant reply or an in-flight draft - adding files still works by automatically forking into a new conversation that inherits the existing context and merges newly selected items - the original conversation remains sealed for auditability, while the fork becomes the active editable workspace Additional cleanup: - updated comments and Javadocs describing ContextPanel lock behavior - clarified refreshChatArea() comments around the sealed conversation + auto-fork flow - clarified startNewConversationWithCurrentContext() naming/comments to make its role in the fork flow explicit - improved confirmation text shown when adding files to a replied conversation - renamed ambiguous "locked" terminology to "context-edit locked" where applicable --- .../src/main/java/iped/app/ui/MenuClass.java | 2 +- .../ai/controller/AIAssistantController.java | 18 ++++++++-- .../iped/app/ui/ai/view/ContextPanel.java | 35 +++++++++++-------- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/MenuClass.java b/iped-app/src/main/java/iped/app/ui/MenuClass.java index b98fb8e997..0e2870a7f1 100644 --- a/iped-app/src/main/java/iped/app/ui/MenuClass.java +++ b/iped-app/src/main/java/iped/app/ui/MenuClass.java @@ -449,7 +449,7 @@ private void openAIAssistantWithItems(List itemsToAdd) { } else { int result = JOptionPane.showConfirmDialog( null, - "This context has already been used in an active chat.\n Would you like to start a new conversation with both the previously uploaded files and the newly added ones?", + "This conversation’s context is no longer directly editable because it already contains an assistant reply. Start a new conversation that keeps the current context and adds the newly selected files?", "New Chat", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java index 0d96d87933..07eb8ea9b1 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -308,6 +308,17 @@ private void loadConversation(Conversation conv) { sidebarView.setSelectedValue(conv, true); } + /** + * Starts a new conversation by carrying over the current context and merging + * additional pending items. + * + * This method is used by the auto-fork flow when the active conversation + * already has an assistant reply and its context is no longer directly editable + * from the ContextPanel UI. + * + * The previous conversation remains unchanged; the new one becomes the active + * editable workspace. + */ public void startNewConversationWithCurrentContext(List pendingItems) { Conversation newConversation = conversationManager.startNewConversation(); @@ -416,9 +427,12 @@ private void refreshChatArea() { chatAreaView.renderHistoricalMessages(renderableMessages); - // Bussines rule: blocks context editing if assistant has replied or there's a draft + // Business rule: once a conversation has an assistant reply, or while an + // assistant draft is streaming, direct context editing in the panel is disabled + // Additional files must be added through the auto-fork flow, which starts a new + // conversation and carries the previous context forward if (contextView != null) { - contextView.setLocked(hasMessages); + contextView.setContextEditLocked(hasMessages); } } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java index 11be0e0b30..f04d514ac8 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java @@ -42,7 +42,7 @@ public class ContextPanel extends JPanel { private JButton clearContextButton; private final ContextListener listener; - private boolean isLocked = false; + private boolean isContextEditLocked = false; /** * Contract for the ContextPanel's event listener, allowing external components (e.g., Controller) @@ -116,7 +116,7 @@ private void initComponents() { clearContextButton.setMargin(new Insets(0, 5, 0, 5)); clearContextButton.setEnabled(false); clearContextButton.addActionListener(e -> { - if (!isLocked && listener != null) { + if (!isContextEditLocked && listener != null) { listener.onClearContextRequested(); } }); @@ -129,11 +129,18 @@ private void initComponents() { } /** - * Blocks or unblocks the context modification actions based on the conversation state. - * When locked, the user cannot add or remove files from the context, and the UI reflects this state visually. - */ - public void setLocked(boolean locked) { - this.isLocked = locked; + * Enables or disables direct context-edit actions in this panel for the + * currently displayed conversation. + * + * This is a UI-only edit lock: it prevents the user from removing items or + * clearing the context from the panel once the conversation already contains + * an assistant reply or an in-flight draft. + * + * It does NOT prevent the controller from creating a new conversation and + * programmatically populating its context (auto-fork flow). + */ + public void setContextEditLocked(boolean locked) { + this.isContextEditLocked = locked; clearContextButton.setEnabled(!locked && contextListModel.getSize() > 0); String currentTitle = contextTitleLabel.getText(); @@ -182,8 +189,8 @@ private JComponent createContextEntryCell(JList list, ContextFileEntry entry, textLabel.setFont(list.getFont()); textLabel.setForeground(isSelected ? list.getSelectionForeground() : list.getForeground()); - // Removes "X" if the panel is locked - JLabel removeLabel = new JLabel(isLocked ? "" : "X"); + // Hide the per-item remove "X" while direct context editing is disabled. + JLabel removeLabel = new JLabel(isContextEditLocked ? "" : "X"); removeLabel.setOpaque(false); removeLabel.setFont(list.getFont().deriveFont(Font.BOLD)); removeLabel.setForeground(new Color(160, 0, 0)); @@ -207,8 +214,8 @@ private void setupMouseListener() { contextList.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { - // Ignores clicks when the panel is locked - if (isLocked) return; + // Ignore direct remove clicks while context editing is disabled for this conversation view + if (isContextEditLocked) return; if (e.getButton() != MouseEvent.BUTTON1) return; @@ -245,8 +252,8 @@ public void updateContextData(List entries) { } else { contextEmptyLabel.setVisible(false); contextList.setVisible(true); - // deactivate clear button if locked - clearContextButton.setEnabled(!isLocked); + // Disable "Clear Context" only for the current conversation's panel-level edit flow + clearContextButton.setEnabled(!isContextEditLocked); int validCount = 0; for (ContextFileEntry entry : entries) { @@ -267,7 +274,7 @@ public void updateContextData(List entries) { contextListModel.addElement(new ContextSummaryRow(summaryText)); } - String lockText = isLocked ? " [LOCKED]" : ""; + String lockText = isContextEditLocked ? " [LOCKED]" : ""; if (invalidCount > 0) { contextTitleLabel.setText("Added Context (" + validCount + " valid, " + invalidCount + " rejected)" + lockText); } else { From e7ed6a4fbf0365dd4e6cdd6b3bf7601faa97e58f Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 28 May 2026 10:20:45 -0300 Subject: [PATCH 110/143] refactor: Move AI context-add workflow from MenuClass to AIAssistantController Refactor the "add items to AI context" flow so MenuClass no longer contains conversation/business logic or accesses AI managers directly. Changes: - removed openAIAssistantWithItems(...) from MenuClass - added controller-owned handler for adding selected items to AI context - kept AIAssistantPanel as the UI entry point and delegated to the controller - centralized processing checks, active conversation checks, direct context append, and auto-fork behavior in AIAssistantController - kept MenuClass responsible only for collecting selected IItems and forwarding them to the assistant entry point --- .../src/main/java/iped/app/ui/MenuClass.java | 43 +-------------- .../ai/controller/AIAssistantController.java | 54 +++++++++++++++++++ .../iped/app/ui/ai/view/AIAssistantPanel.java | 12 +++++ 3 files changed, 68 insertions(+), 41 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/MenuClass.java b/iped-app/src/main/java/iped/app/ui/MenuClass.java index 0e2870a7f1..4790bfaa08 100644 --- a/iped-app/src/main/java/iped/app/ui/MenuClass.java +++ b/iped-app/src/main/java/iped/app/ui/MenuClass.java @@ -31,12 +31,7 @@ import javax.swing.JRadioButtonMenuItem; import javax.swing.JTable; import javax.swing.KeyStroke; -import javax.swing.JOptionPane; -import javax.swing.SwingUtilities; -import iped.app.ui.ai.context.AIContextManager; -import iped.app.ui.ai.context.ConversationManager; -import iped.app.ui.ai.model.Conversation; import iped.app.ui.ai.view.AIAssistantPanel; import iped.app.ui.themes.Theme; import iped.app.ui.themes.ThemeManager; @@ -353,7 +348,7 @@ public void actionPerformed(ActionEvent e) { } if (!itemsToAdd.isEmpty()) { - openAIAssistantWithItems(itemsToAdd); + AIAssistantPanel.getInstance().addItemsToContext(itemsToAdd); } }); this.add(addAllHighlightedToAIContext); @@ -368,7 +363,7 @@ public void actionPerformed(ActionEvent e) { List itemsToAdd = getCheckedItems(); if (!itemsToAdd.isEmpty()) { - openAIAssistantWithItems(itemsToAdd); + AIAssistantPanel.getInstance().addItemsToContext(itemsToAdd); } }); this.add(addAllCheckedToAIContext); @@ -428,40 +423,6 @@ private IItem resolveItemFromRow(JTable table, int viewRow) { return App.get().appCase.getItemByItemId(itemId); } - private void openAIAssistantWithItems(List itemsToAdd) { - Conversation activeConversation = ConversationManager.getInstance().getActiveConversation(); - AIAssistantPanel assistantPanel = AIAssistantPanel.getInstance(); - - if (assistantPanel.isProcessing()) { - JOptionPane.showMessageDialog( - null, - "Aguarde a resposta atual terminar antes de adicionar novos itens ao contexto.", - "AI Assistant", - JOptionPane.INFORMATION_MESSAGE); - return; - } - - if (activeConversation == null) { - assistantPanel.startNewConversationWithCurrentContext(itemsToAdd); - } else if (!activeConversation.hasAssistantReply()) { - AIContextManager.getInstance().addContextFiles(itemsToAdd); - assistantPanel.showFrame(); - } else { - int result = JOptionPane.showConfirmDialog( - null, - "This conversation’s context is no longer directly editable because it already contains an assistant reply. Start a new conversation that keeps the current context and adds the newly selected files?", - "New Chat", - JOptionPane.YES_NO_OPTION, - JOptionPane.QUESTION_MESSAGE); - - if (result == JOptionPane.YES_OPTION) { - assistantPanel.startNewConversationWithCurrentContext(itemsToAdd); - } else if (result == JOptionPane.NO_OPTION) { - assistantPanel.showFrame(); - } - } - } - public void addExportTreeMenuItems(JComponent menu) { exportTree = new JMenuItem(Messages.getString("MenuClass.ExportTree")); //$NON-NLS-1$ exportTree.addActionListener(menuListener); diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java index 07eb8ea9b1..60fe084571 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -531,4 +531,58 @@ private JPanel createTasksPanel() { return panel; } + + /** + * Evaluates the current chat state and orchestrates the addition of new items to the AI context. + *

+ * This method enforces the "context-edit lock" behavior: + *

    + *
  • If a response is currently streaming, it blocks the addition to prevent race conditions.
  • + *
  • If the active conversation is "sealed" (contains an assistant reply), it prompts the user + * to auto-fork into a new conversation merging the old and new contexts.
  • + *
  • If the conversation is empty or a draft, it appends the items normally.
  • + *
+ * + * @param itemsToAdd The list of IPED items selected by the user. + */ + public void addItemsToContext(List itemsToAdd) { + if (itemsToAdd == null || itemsToAdd.isEmpty()) { + return; + } + + if (mainView.isProcessing()) { + JOptionPane.showMessageDialog( + mainView.getFrame(), + "Aguarde a resposta atual terminar antes de adicionar novos itens ao contexto.", + "AI Assistant", + JOptionPane.INFORMATION_MESSAGE); + return; + } + + Conversation activeConversation = conversationManager.getActiveConversation(); + + if (activeConversation == null) { + startNewConversationWithCurrentContext(itemsToAdd); + return; + } + + if (!activeConversation.hasAssistantReply()) { + contextManager.addContextFiles(itemsToAdd); + mainView.showFrame(); + return; + } + + int result = JOptionPane.showConfirmDialog( + mainView.getFrame(), + "This conversation’s context is no longer directly editable because it already contains an assistant reply. Start a new conversation that keeps the current context and adds the newly selected files?", + "New Chat", + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE); + + if (result == JOptionPane.YES_OPTION) { + startNewConversationWithCurrentContext(itemsToAdd); // Auto-Fork + } else if (result == JOptionPane.NO_OPTION) { + mainView.showFrame(); + } + } } \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 34df73faeb..4934a60d3b 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -314,4 +314,16 @@ public ChatAreaPanel getChatAreaPanel() { public JPanel getTasksPanel() { return tasksPanel; } + + /** + * Exposes the context addition workflow to external UI components (e.g., IPED context menus). + * Delegates all state evaluation and routing logic to the {@link AIAssistantController}. + * + * @param itemsToAdd The list of items selected by the investigator to add to the AI context. + */ + public void addItemsToContext(List itemsToAdd) { + if (controller != null) { + controller.addItemsToContext(itemsToAdd); + } + } } \ No newline at end of file From 6689cdc84dd01e94ece2fb88fc9b07218ae581f8 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 15 Jun 2026 16:00:52 -0300 Subject: [PATCH 111/143] feat(ai-backend): add AIInitMultiChatFullRequest DTO for raw HTML initialization This DTO refers only to the init step. Streaming will be handled by the already existing summarized multi-chat flow (chats_hashes, user_question, previousmessages). --- .../backend/AIInitMultiChatFullRequest.java | 26 +++++++++++++++++++ .../ai/backend/AIMultiChatStreamRequest.java | 6 ++--- 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/backend/AIInitMultiChatFullRequest.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIInitMultiChatFullRequest.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIInitMultiChatFullRequest.java new file mode 100644 index 0000000000..6643e448c5 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIInitMultiChatFullRequest.java @@ -0,0 +1,26 @@ +package iped.app.ui.ai.backend; + +import com.google.gson.annotations.SerializedName; +import java.util.List; + +/** + * DTO for initializing a full, non-summarized multi-chat session. + * Expects a list of raw WhatsApp HTML export strings. + */ +public class AIInitMultiChatFullRequest { + + @SerializedName("chat_contents") + private List chatContents; + + public AIInitMultiChatFullRequest(List chatContents) { + this.chatContents = chatContents; + } + + public List getChatContents() { + return chatContents; + } + + public void setChatContents(List chatContents) { + this.chatContents = chatContents; + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIMultiChatStreamRequest.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIMultiChatStreamRequest.java index 656c846e3c..2be14e1f76 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIMultiChatStreamRequest.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIMultiChatStreamRequest.java @@ -3,11 +3,9 @@ import java.util.List; /** - * A Data Transfer Object (DTO) representing the payload sent to query a Multi-Chat session + * A Data Transfer Object (DTO) for streaming both Summarized and Full multi-chat responses *

- * This request is sent to the {@code /api/multichat/stream} endpoint. It combines - * multiple chat session IDs, the user's current prompt, and the ongoing conversational - * memory into a single JSON structure. + * Maps to the backend's MultiChatConversationRequest schema *

*/ public class AIMultiChatStreamRequest { From 433cdf2db71fcbaf530c52d77c432123d8da1223 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Mon, 15 Jun 2026 17:00:33 -0300 Subject: [PATCH 112/143] feat(ai-backend): add support for multi-chat-full initialization and streaming - Add `initMultiChatFull` for initializing chats from raw HTML content. - Handle mixed backend responses by collecting valid chat hashes and logging rejected chats without interrupting the initialization process. - Add `streamMultiChatFullResponse` to send queries through `/api/multichat_full/stream`. - Reuse the existing `AIMultiChatStreamRequest` DTO and SSE processing logic used by the summarized multi-chat flow. --- .../app/ui/ai/backend/AIBackendClient.java | 295 +++++++++++------- .../app/ui/ai/backend/AIBackendService.java | 20 ++ 2 files changed, 202 insertions(+), 113 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java index 5cc3717964..6f50e1da40 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -1,6 +1,7 @@ package iped.app.ui.ai.backend; import com.google.gson.Gson; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -37,7 +38,7 @@ public class AIBackendClient implements AIBackendService { private static final String THINKING_BLOCK_PREFIX = "[[AI_THINKING]]"; private static final String THINKING_BLOCK_SUFFIX = "[[/AI_THINKING]]"; - + private final AIBackendConfig config; private final HttpClient httpClient; private final Gson gson; @@ -149,71 +150,13 @@ public void streamChatResponse(String chatHash, String question, List response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - + if (response.statusCode() != 200) { throw new AIBackendException("Backend returned HTTP " + response.statusCode()); } - // Process the Server-Sent Events (SSE) stream - try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { - String line; - - // Add a flag to track the first final token - boolean isFirstFinalToken = true; - boolean isThinkingBlockOpen = false; - - // Blocks here until a new line arrives over the network, then processes it - while ((line = reader.readLine()) != null) { - // SSE protocol dictates data lines begin with "data: " - if (line.startsWith("data: ")) { - String jsonData = line.substring(6).trim(); // Strips the "data: " - - // Ignore keep-alive pings or the final closure signal - if (jsonData.isEmpty() || jsonData.equals("[DONE]")) continue; - - // Parse the JSON, figure what type of message it is - JsonObject eventObj = JsonParser.parseString(jsonData).getAsJsonObject(); - String type = eventObj.has("type") ? eventObj.get("type").getAsString() : ""; - - // Handle thinking_done which has no content field - if (type.equals("thinking_done")) { - if (isThinkingBlockOpen) { - eventHandler.accept(THINKING_BLOCK_SUFFIX + "\n"); - isThinkingBlockOpen = false; - } - continue; - } - - // Isolate the actual content for other event types - if (eventObj.has("content")) { - String content = eventObj.get("content").getAsString(); - - if (type.equals("status")) { - // Format metadata in italics for the UI, with explicit 'status' indication - eventHandler.accept("\n_**[Status]:** " + content + "_\n"); - } else if (type.equals("thinking")) { - if (!isThinkingBlockOpen) { - // Emit a dedicated block marker to avoid markdown parsing conflicts - eventHandler.accept("\n" + THINKING_BLOCK_PREFIX); - isThinkingBlockOpen = true; - } - - eventHandler.accept(content); - } else if (type.equals("final")) { - // Check the flag before appending - if (isFirstFinalToken) { - eventHandler.accept("\n**[FINAL ANSWER]:**\n" + content); - isFirstFinalToken = false; - } else { - eventHandler.accept(content); - } - } else if (type.equals("error")) { - throw new AIBackendException("Backend Streaming Error: " + content); - } - } - } - } - } + // Process the Server-Sent Events (SSE) stream using helper method + processSseStream(response, eventHandler); } catch (Exception e) { throw new AIBackendException("Failed to stream chat response: " + e.getMessage(), e); @@ -294,68 +237,194 @@ public void streamMultiChatResponse(List chatHashes, String question, Li .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - + if (response.statusCode() != 200) { throw new AIBackendException("Backend returned HTTP " + response.statusCode()); } - - // The SSE parsing logic is identical to the single-chat stream - try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { - String line; - - // Add a flag to track the first final token - boolean isFirstFinalToken = true; - boolean isThinkingBlockOpen = false; - - while ((line = reader.readLine()) != null) { - if (line.startsWith("data: ")) { - String jsonData = line.substring(6).trim(); - - if (jsonData.isEmpty() || jsonData.equals("[DONE]")) continue; - - JsonObject eventObj = JsonParser.parseString(jsonData).getAsJsonObject(); - String type = eventObj.has("type") ? eventObj.get("type").getAsString() : ""; - - // Handle thinking_done which has no content field - if (type.equals("thinking_done")) { - if (isThinkingBlockOpen) { - eventHandler.accept(THINKING_BLOCK_SUFFIX + "\n"); - isThinkingBlockOpen = false; - } - continue; + + // Process the Server-Sent Events (SSE) stream using the helper method + processSseStream(response, eventHandler); + + } catch (Exception e) { + throw new AIBackendException("Failed to stream multi-chat response: " + e.getMessage(), e); + } + } + + /** + * Synchronously initializes a full multi-chat session using raw HTML content. + *

+ * Unlike the summarized multi-chat flow, this endpoint sends complete chat + * contents to the backend. The backend processes each chat independently and + * returns an array containing either a generated hash or an error for each item. + *

+ * + * @param requestPayload The full multi-chat initialization payload containing + * raw chat HTML content + * @return A list of chat hashes successfully initialized by the backend + * @throws AIBackendException if the request fails or no chats can be initialized + */ + @Override + public List initMultiChatFull(AIInitMultiChatFullRequest requestPayload) throws AIBackendException { + try { + String jsonBody = gson.toJson(requestPayload); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.getBaseUrl() + "/api/init_multichat_full")) + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .header("Authorization", "Bearer " + config.getApiKey()) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new AIBackendException("Backend returned HTTP " + response.statusCode() + ": " + response.body()); + } + + JsonObject root = JsonParser.parseString(response.body()).getAsJsonObject(); + JsonArray responseArray = root.getAsJsonArray("response"); + + List hashes = new ArrayList<>(); + + if (responseArray != null) { + for (JsonElement element : responseArray) { + JsonObject item = element.getAsJsonObject(); + JsonElement responseVal = item.get("response"); + + // The backend returns false if the content is not HTML. + // We only want to collect valid MD5 hashes (which are returned as strings). + if (responseVal != null && responseVal.isJsonPrimitive() && responseVal.getAsJsonPrimitive().isString()) { + hashes.add(responseVal.getAsString()); + } else if (item.has("error")) { + System.err.println("Backend rejected a chat during initMultiChatFull: " + item.get("error").getAsString()); + } + } + } + + // Fail only if every chat was rejected + if (hashes.isEmpty()) { + throw new AIBackendException("Backend failed to initialize any full multi-chat hashes. Check if the HTML is valid."); + } + + return hashes; + + } catch (Exception e) { + throw new AIBackendException("Failed to initialize full multi-chat: " + e.getMessage(), e); + } + } + + /** + * Streams a response for a full multi-chat session using Server-Sent Events (SSE). + *

+ * The request payload is identical to the summarized multi-chat flow. The only + * difference is the backend endpoint, which operates on sessions initialized + * with raw HTML chat content. + *

+ * + * @param chatHashes Hashes returned during full multi-chat initialization + * @param question User question + * @param history Previous conversation messages + * @param eventHandler Callback that receives streamed content chunks + * @throws AIBackendException if the request or stream fails + */ + @Override + public void streamMultiChatFullResponse(List chatHashes, String question, List history, Consumer eventHandler) throws AIBackendException { + try { + // Reuse the existing MultiChat DTO since the payload structure is identical + AIMultiChatStreamRequest payload = new AIMultiChatStreamRequest(chatHashes, question, history); + String jsonBody = gson.toJson(payload); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.getBaseUrl() + "/api/multichat_full/stream")) + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .header("Authorization", "Bearer " + config.getApiKey()) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + throw new AIBackendException("Backend returned HTTP " + response.statusCode()); + } + + // Process the Server-Sent Events (SSE) stream using the helper + processSseStream(response, eventHandler); + + } catch (Exception e) { + throw new AIBackendException("Failed to stream full multi-chat response: " + e.getMessage(), e); + } + } + + /** + * Helper method to parse Server-Sent Events (SSE) from the backend input stream + * + * @param response The HTTP response containing the InputStream + * @param eventHandler Callback invoked for each streamed content chunk + * @throws Exception if parsing or reading the stream fails + */ + private void processSseStream(HttpResponse response, Consumer eventHandler) throws Exception { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { + String line; + + // Add a flag to track the first final token + boolean isFirstFinalToken = true; + boolean isThinkingBlockOpen = false; + + // Blocks here until a new line arrives over the network, then processes it + while ((line = reader.readLine()) != null) { + // SSE protocol dictates data lines begin with "data: " + if (line.startsWith("data: ")) { + String jsonData = line.substring(6).trim(); // Strips the "data: " + + // Ignore keep-alive pings or the final closure signal + if (jsonData.isEmpty() || jsonData.equals("[DONE]")) { + continue; + } + + // Parse the JSON, figure what type of message it is + JsonObject eventObj = JsonParser.parseString(jsonData).getAsJsonObject(); + String type = eventObj.has("type") ? eventObj.get("type").getAsString() : ""; + + // Handle thinking_done which has no content field + if (type.equals("thinking_done")) { + if (isThinkingBlockOpen) { + eventHandler.accept(THINKING_BLOCK_SUFFIX + "\n"); + isThinkingBlockOpen = false; } - - if (eventObj.has("content")) { - String content = eventObj.get("content").getAsString(); - - if (type.equals("status")) { - // Format metadata in italics for the UI, with explicit 'status' indication - eventHandler.accept("\n_**[Status]:** " + content + "_\n"); - } else if (type.equals("thinking")) { - if (!isThinkingBlockOpen) { - // Emit a dedicated block marker to avoid markdown parsing conflicts - eventHandler.accept("\n" + THINKING_BLOCK_PREFIX); - isThinkingBlockOpen = true; - } + continue; + } + + // Isolate the actual content for other event types + if (eventObj.has("content")) { + String content = eventObj.get("content").getAsString(); + + if (type.equals("status")) { + // Format metadata in italics for the UI, with explicit 'status' indication + eventHandler.accept("\n_**[Status]:** " + content + "_\n"); + } else if (type.equals("thinking")) { + if (!isThinkingBlockOpen) { + // Emit a dedicated block marker to avoid markdown parsing conflicts + eventHandler.accept("\n" + THINKING_BLOCK_PREFIX); + isThinkingBlockOpen = true; + } + eventHandler.accept(content); + } else if (type.equals("final")) { + // Check the flag before appending + if (isFirstFinalToken) { + eventHandler.accept("\n**[FINAL ANSWER]:**\n" + content); + isFirstFinalToken = false; + } else { eventHandler.accept(content); - } else if (type.equals("final")) { - // Check the flag before appending - if (isFirstFinalToken) { - eventHandler.accept("\n**[FINAL ANSWER]:**\n" + content); - isFirstFinalToken = false; - } else { - eventHandler.accept(content); - } - } else if (type.equals("error")) { - throw new AIBackendException("Backend Streaming Error: " + content); } + } else if (type.equals("error")) { + throw new AIBackendException("Backend Streaming Error: " + content); } } } } - } catch (Exception e) { - throw new AIBackendException("Failed to stream multi-chat response: " + e.getMessage(), e); } } } diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java index e65d30c619..08155782d0 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java @@ -48,4 +48,24 @@ public interface AIBackendService { * @throws AIBackendException if the streaming connection fails or returns an error token */ void streamMultiChatResponse(List chatHashes, String question, List history, Consumer eventHandler) throws AIBackendException; + + // --- MULTI-CHAT-FULL ENDPOINTS --- + + /** + * Initializes a full multi-chat session by sending raw HTML contents. + * @param request The initialization payload containing a list of raw HTML strings. + * @return A list of successfully generated MD5 chat hashes. + * @throws AIBackendException if the server rejects the HTML or is unreachable. + */ + List initMultiChatFull(AIInitMultiChatFullRequest request) throws AIBackendException; + + /** + * Streams the response for a full multi-chat session using Server-Sent Events. + * @param chatHashes The list of session hashes returned by initMultiChatFull. + * @param question The user's prompt. + * @param history The chat history providing multi-turn conversational memory. + * @param eventHandler Callback triggered for every SSE token received. + * @throws AIBackendException if the streaming connection fails. + */ + void streamMultiChatFullResponse(List chatHashes, String question, List history, Consumer eventHandler) throws AIBackendException; } \ No newline at end of file From 8e054fe912814eb9e50ae93daaec29a10bda8e0f Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 16 Jun 2026 14:38:54 -0300 Subject: [PATCH 113/143] feat(ai-backend): add payload factory support for raw HTML multi-chat-full --- .../iped/app/ui/ai/util/AIPayloadFactory.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/iped-app/src/main/java/iped/app/ui/ai/util/AIPayloadFactory.java b/iped-app/src/main/java/iped/app/ui/ai/util/AIPayloadFactory.java index 38990999bc..0f6c1718cc 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/util/AIPayloadFactory.java +++ b/iped-app/src/main/java/iped/app/ui/ai/util/AIPayloadFactory.java @@ -2,7 +2,9 @@ import iped.app.ui.ai.model.ContextFileEntry; import iped.app.ui.ai.backend.AIInitMultiChatRequest; +import iped.app.ui.ai.backend.AIInitMultiChatFullRequest; import iped.app.ui.ai.backend.AIInitMultiChatRequest.SummarizedChat; +import iped.app.ui.ai.util.AIWhatsappChatExtractor; import iped.data.IItem; import iped.properties.ExtraProperties; @@ -94,6 +96,46 @@ public static AIInitMultiChatRequest buildMultiChatRequest(List contextEntries) { + List rawHtmlContents = new ArrayList<>(); + + AIWhatsappChatExtractor extractor = new AIWhatsappChatExtractor(); + + for (ContextFileEntry entry : contextEntries) { + // Skip files that failed baseline validation + if (!entry.isValidForContext()) { + continue; + } + + try { + // Pull the raw HTML from the IPED IItem + String rawHtml = extractor.extractHtml(entry.getItem()); + + if (rawHtml != null && !rawHtml.trim().isEmpty()) { + rawHtmlContents.add(rawHtml); + } else { + System.err.println("Warning: Extracted HTML was empty for item ID " + entry.getItem().getId()); + } + } catch (Exception e) { + System.err.println("Failed to extract HTML for item ID " + entry.getItem().getId() + ": " + e.getMessage()); + } + } + + if (rawHtmlContents.isEmpty()) { + throw new IllegalArgumentException("No valid HTML content was extracted for the full multi-chat analysis."); + } + + return new AIInitMultiChatFullRequest(rawHtmlContents); + } + /** * Safely extracts a list of strings from IPED's flexible metadata/attribute storage. */ From 48d265c06ca40f4b64ecc1c6276c9fbdd540b6d8 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 16 Jun 2026 15:29:41 -0300 Subject: [PATCH 114/143] feat(ai-coordinator): implement routing for full multi-chat endpoints - Add MAX_FULL_CHAT_FILES threshold set to 5 files. - Update chat initialization in askQuestion to route requests with 2 to 5 valid context files to backendService.initMultiChatFull using raw HTML. - Integrate AIInitMultiChatFullRequest into the AI orchestration pipeline. --- .../iped/app/ui/ai/AIChatCoordinator.java | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 502a9873ce..35b26f8db5 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -3,6 +3,7 @@ import iped.app.ui.ai.backend.AIBackendService; import iped.app.ui.ai.backend.AIInitMultiChatRequest; import iped.app.ui.ai.backend.AIStreamChatRequest; +import iped.app.ui.ai.backend.AIInitMultiChatFullRequest; import iped.app.ui.ai.util.AIWhatsappChatExtractor; import iped.app.ui.ai.util.AIPayloadFactory; import iped.app.ui.ai.util.ConversationPersistence; @@ -28,6 +29,8 @@ */ public class AIChatCoordinator { + private static final int MAX_FULL_CHAT_FILES = 5; + private final AIBackendService backendService; private final AIWhatsappChatExtractor extractor; @@ -99,8 +102,13 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o String html = extractor.extractHtml(item); String hash = backendService.initChat(html); currentChatHashes.add(hash); + } else if (validEntries.size() <= MAX_FULL_CHAT_FILES) { + // Multi chat full (raw HTML - fewer than 6 chats) + AIInitMultiChatFullRequest request = AIPayloadFactory.buildMultiChatFullRequest(validEntries); + List hashes = backendService.initMultiChatFull(request); + currentChatHashes.addAll(hashes); } else { - // Multi chat + // Normal Multi chat (summarized, 6 or more chats) AIInitMultiChatRequest request = AIPayloadFactory.buildMultiChatRequest(validEntries); List hashes = backendService.initMultiChat(request); currentChatHashes.addAll(hashes); @@ -136,10 +144,20 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o }); } else if (currentChatHashes.size() > 1) { // Multi chat stream - backendService.streamMultiChatResponse(currentChatHashes, question, chatHistory, token -> { - uiCallback.accept(token); - fullResponse.append(token); - }); + // Check context size to route to the correct endpoint) + if (validEntries.size() <= MAX_FULL_CHAT_FILES) { + // Multi chat full stream + backendService.streamMultiChatFullResponse(currentChatHashes, question, chatHistory, token -> { + uiCallback.accept(token); + fullResponse.append(token); + }); + } else { + // Multi chat summarized stream + backendService.streamMultiChatResponse(currentChatHashes, question, chatHistory, token -> { + uiCallback.accept(token); + fullResponse.append(token); + }); + } } else { throw new IllegalStateException("Cannot stream response: No active chat hashes found."); } From 276bb03cdb4b555d54c1f2355b45c5f52f1183c8 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 18 Jun 2026 14:24:15 -0300 Subject: [PATCH 115/143] fix bug where continuing a chat from persistence caused a chat not found error --- .../src/main/java/iped/app/ui/ai/AIChatCoordinator.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 35b26f8db5..66a6eae75f 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -169,8 +169,15 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o chatHistory.add(new AIStreamChatRequest.AIMessage("assistant", fullResponse.toString())); } catch (Exception e) { + String errorMessage = e.getMessage(); onError.accept("Backend error: " + e.getMessage()); + // If the backend restarted and its cache was wiped, it will throw a "not found" error. + // Clear the hashes to force the next attempt to re-upload the HTM + if (errorMessage != null && (errorMessage.toLowerCase().contains("nao encontrado") || errorMessage.toLowerCase().contains("não encontrado"))) { + currentChatHashes.clear(); + } + // Only invalidate cache if initialization itself failed // If error happened during streaming, preserve the hashes so the user can retry if (!initializationCompleted) { From c00a18503bd62b1203238e46fa526f8e61ba5fed Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Fri, 19 Jun 2026 13:49:46 -0300 Subject: [PATCH 116/143] feat: change multi chat routing logic to be based on chunkIds rather than number of chats - chunks <= 10: multi chat full - chunks > 10: multi chat (summaries) --- .../iped/app/ui/ai/AIChatCoordinator.java | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 66a6eae75f..4ff2215511 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -13,11 +13,13 @@ import iped.app.ui.ai.context.AIContextManager; import iped.app.ui.ai.context.ConversationManager; import iped.data.IItem; +import iped.properties.ExtraProperties; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; import java.util.stream.Collectors; +import java.util.Collection; /** * The central orchestrator that coordinates the flow of data between the UI, @@ -29,8 +31,6 @@ */ public class AIChatCoordinator { - private static final int MAX_FULL_CHAT_FILES = 5; - private final AIBackendService backendService; private final AIWhatsappChatExtractor extractor; @@ -66,6 +66,9 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o .stream() .filter(ContextFileEntry::isValidForContext) .collect(Collectors.toList()); + + // Calculate the total chunks across all selected chats + final int totalChunks = calculateTotalChunks(validEntries); // If a question is typed before the background thread finishes restoring the UI, // this safely blocks the user for that fraction of a second. @@ -102,13 +105,13 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o String html = extractor.extractHtml(item); String hash = backendService.initChat(html); currentChatHashes.add(hash); - } else if (validEntries.size() <= MAX_FULL_CHAT_FILES) { - // Multi chat full (raw HTML - fewer than 6 chats) + } else if (totalChunks <= 10) { + // Multi chat full (raw HTML - fewer than 11 chunks) AIInitMultiChatFullRequest request = AIPayloadFactory.buildMultiChatFullRequest(validEntries); List hashes = backendService.initMultiChatFull(request); currentChatHashes.addAll(hashes); } else { - // Normal Multi chat (summarized, 6 or more chats) + // Normal Multi chat (summarized, 11 or more chunks) AIInitMultiChatRequest request = AIPayloadFactory.buildMultiChatRequest(validEntries); List hashes = backendService.initMultiChat(request); currentChatHashes.addAll(hashes); @@ -144,8 +147,8 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o }); } else if (currentChatHashes.size() > 1) { // Multi chat stream - // Check context size to route to the correct endpoint) - if (validEntries.size() <= MAX_FULL_CHAT_FILES) { + // Check chunk size to route to the correct endpoint + if (totalChunks <= 10) { // Multi chat full stream backendService.streamMultiChatFullResponse(currentChatHashes, question, chatHistory, token -> { uiCallback.accept(token); @@ -224,4 +227,45 @@ public void loadHistoricalContext(List hashes, List itemIds, Li } } } + + /** + * Calculates the total number of chunks across a list of chat files. + */ + private int calculateTotalChunks(List entries) { + int total = 0; + for (ContextFileEntry entry : entries) { + IItem item = entry.getItem(); + if (item == null) continue; + + int count = 0; + + // Try ExtraAttributes (Runtime memory) + Object extraValue = item.getExtraAttribute(ExtraProperties.CHUNK_IDS); + if (extraValue instanceof java.util.Collection) { + count = ((Collection) extraValue).size(); + } else if (extraValue instanceof Object[]) { + count = ((Object[]) extraValue).length; + } else if (extraValue instanceof String) { + String str = ((String) extraValue).trim(); + if (!str.isEmpty()) { + count = str.split(",").length; + } + } + // Try Lucene Metadata (Disk) + else if (item.getMetadata() != null) { + String[] values = item.getMetadata().getValues(ExtraProperties.CHUNK_IDS); + if (values != null && values.length > 0) { + count = values.length; + } else { + String single = item.getMetadata().get(ExtraProperties.CHUNK_IDS); + if (single != null && !single.trim().isEmpty()) { + count = single.split(",").length; + } + } + } + + total += count; + } + return total; + } } \ No newline at end of file From d7db76b3b5a092d56b0b7c4e6ba767f3ea5b88c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Mon, 22 Jun 2026 13:56:32 -0300 Subject: [PATCH 117/143] Add "modo chat resumido" label. --- .../iped/app/ui/ai/AIChatCoordinator.java | 2 +- .../ai/controller/AIAssistantController.java | 21 +++++++++++++++++-- .../iped/app/ui/ai/view/ContextPanel.java | 19 ++++++++++++++++- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 4ff2215511..6476be0101 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -231,7 +231,7 @@ public void loadHistoricalContext(List hashes, List itemIds, Li /** * Calculates the total number of chunks across a list of chat files. */ - private int calculateTotalChunks(List entries) { + public static int calculateTotalChunks(List entries) { int total = 0; for (ContextFileEntry entry : entries) { IItem item = entry.getItem(); diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java index 60fe084571..e82302f9f0 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -87,7 +87,16 @@ public void initialize() { // 5. Render initial state (messages, lists, context, etc.) sidebarView.updateConversationsList(conversationManager.getConversations()); - contextView.updateContextData(contextManager.getContextEntriesForUI()); + + List initialEntries = contextManager.getContextEntriesForUI(); + contextView.updateContextData(initialEntries); + + List validE = initialEntries.stream() + .filter(iped.app.ui.ai.model.ContextFileEntry::isValidForContext) + .collect(Collectors.toList()); + int totalChunks = AIChatCoordinator.calculateTotalChunks(validE); + contextView.setSummarizedMode(validE.size() > 1 && totalChunks > 10); + refreshChatArea(); } @@ -162,7 +171,15 @@ public void contextChanged(ContextChangeEvent event) { // 1. Inject new data into the passive view for rendering on the correct thread SwingUtilities.invokeLater(() -> { if (contextView != null) { - contextView.updateContextData(contextManager.getContextEntriesForUI()); + List uiEntries = contextManager.getContextEntriesForUI(); + contextView.updateContextData(uiEntries); + + List validEntries = uiEntries.stream() + .filter(iped.app.ui.ai.model.ContextFileEntry::isValidForContext) + .collect(Collectors.toList()); + int totalChunks = AIChatCoordinator.calculateTotalChunks(validEntries); + boolean isSummarized = validEntries.size() > 1 && totalChunks > 10; + contextView.setSummarizedMode(isSummarized); } }); diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java index f04d514ac8..84e8667f6b 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java @@ -39,6 +39,7 @@ public class ContextPanel extends JPanel { private DefaultListModel contextListModel; private JLabel contextTitleLabel; private JLabel contextEmptyLabel; + private JLabel chatModeLabel; private JButton clearContextButton; private final ContextListener listener; @@ -85,9 +86,19 @@ private void configureLayout() { setBorder(BorderFactory.createCompoundBorder( BorderFactory.createTitledBorder(BorderFactory.createEtchedBorder(), ""), BorderFactory.createEmptyBorder(8, 8, 8, 8))); + + JPanel topPanel = new JPanel(new BorderLayout()); contextTitleLabel = new JLabel("Added Context (0 files)"); contextTitleLabel.setFont(contextTitleLabel.getFont().deriveFont(Font.BOLD)); - add(contextTitleLabel, BorderLayout.NORTH); + topPanel.add(contextTitleLabel, BorderLayout.WEST); + + chatModeLabel = new JLabel("Modo Chat Resumido ativo"); + chatModeLabel.setForeground(new Color(200, 100, 0)); + chatModeLabel.setFont(chatModeLabel.getFont().deriveFont(Font.BOLD, 10f)); + chatModeLabel.setVisible(false); + topPanel.add(chatModeLabel, BorderLayout.EAST); + + add(topPanel, BorderLayout.NORTH); } private void initComponents() { @@ -128,6 +139,12 @@ private void initComponents() { add(actionPanel, BorderLayout.EAST); } + public void setSummarizedMode(boolean summarized) { + if (chatModeLabel != null) { + chatModeLabel.setVisible(summarized); + } + } + /** * Enables or disables direct context-edit actions in this panel for the * currently displayed conversation. From 859fc10df6ba265f24c8666c99560939bc39aa63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Wed, 24 Jun 2026 14:57:22 -0300 Subject: [PATCH 118/143] feature: UI logic for agent mode --- .../iped/app/ui/ai/AIChatCoordinator.java | 22 ++++ .../ui/ai/context/ConversationManager.java | 8 ++ .../ai/controller/AIAssistantController.java | 116 ++++++++++++++---- .../iped/app/ui/ai/model/Conversation.java | 26 +++- .../iped/app/ui/ai/view/AIAssistantPanel.java | 6 + .../iped/app/ui/ai/view/SidebarPanel.java | 23 +++- 6 files changed, 166 insertions(+), 35 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 6476be0101..eac48a82b3 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -194,6 +194,28 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o } + /** + * Placeholder for agent-style chat. No context files, no chat hashes, no initialization. + * The actual connection to a local agent (e.g. opencode) will be implemented later. + *

+ * For now, echoes the question back to allow UI testing of the agent flow. + */ + public void askAgentQuestion(String question, Consumer uiCallback, Runnable onComplete, Consumer onError) { + new Thread(() -> { + try { + uiCallback.accept("**[Agent]:** Thinking...\n\n"); + chatHistory.add(new AIStreamChatRequest.AIMessage("user", question)); + chatHistory.add(new AIStreamChatRequest.AIMessage("assistant", question)); + uiCallback.accept(question); + uiCallback.accept("\n\n"); + } catch (Exception e) { + onError.accept("Agent error: " + e.getMessage()); + } finally { + onComplete.run(); + } + }).start(); + } + public void clearHistory() { this.chatHistory.clear(); diff --git a/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java index e056291945..13c6dd2a4a 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java @@ -62,7 +62,15 @@ public void setActiveConversation(Conversation conversation) { * Initializes a fresh, empty conversation and sets it as active. */ public Conversation startNewConversation() { + return startNewConversation(false); + } + + /** + * Initializes a fresh conversation with the specified agent flag and sets it as active. + */ + public Conversation startNewConversation(boolean isAgent) { Conversation newConv = new Conversation(); + newConv.setAgentConversation(isAgent); setActiveConversation(newConv); return newConv; } diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java index e82302f9f0..81a0afab6d 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -108,6 +108,11 @@ public void onNewChatRequested() { startNewChat(); } + @Override + public void onNewAgentChatRequested() { + startNewAgentChat(); + } + @Override public void onDeleteRequested(Conversation conversation) { deleteChat(conversation); @@ -187,7 +192,7 @@ public void contextChanged(ContextChangeEvent event) { if (isSwitchingChats) return; // Abort if the system is switching chats Conversation activeConv = conversationManager.getActiveConversation(); - if (activeConv != null) { + if (activeConv != null && !activeConv.isAgentConversation()) { List currentIds = contextManager.getContextFiles().stream() .map(IItem::getId) .collect(Collectors.toList()); @@ -222,6 +227,7 @@ private boolean ensureChatServiceInitialized() { private void startNewChat() { conversationManager.startNewConversation(); + mainView.setContextPanelVisible(true); clearChatScreenAndMemory(); contextManager.clearContext(); sidebarView.updateConversationsList(conversationManager.getConversations()); @@ -231,6 +237,18 @@ private void startNewChat() { chatAreaView.getInputArea().requestFocusInWindow(); } + private void startNewAgentChat() { + conversationManager.startNewConversation(true); + mainView.setContextPanelVisible(false); + clearChatScreenAndMemory(); + contextManager.clearContext(); + sidebarView.updateConversationsList(conversationManager.getConversations()); + refreshChatArea(); + + addMessage("System", "Started a new agent conversation session.", "system"); + chatAreaView.getInputArea().requestFocusInWindow(); + } + private void deleteChat(Conversation conv) { ConversationPersistence.deleteConversation(conv); conversationManager.removeConversation(conv); @@ -257,11 +275,20 @@ private void deleteChat(Conversation conv) { private void loadConversation(Conversation conv) { isSwitchingChats = true; conversationManager.setActiveConversation(conv); - + mainView.setContextPanelVisible(!conv.isAgentConversation()); + if (coordinator != null) { coordinator.loadHistoricalContext(conv.getChatHashes(), conv.getContextIds(), conv.getMessages()); } - + + if (conv.isAgentConversation()) { + isSwitchingChats = false; + if (chatAreaView != null) chatAreaView.forceDiscardStreaming(); + refreshChatArea(); + sidebarView.setSelectedValue(conv, true); + return; + } + contextManager.clearContext(); new Thread(() -> { @@ -381,8 +408,9 @@ private void handleSendAction() { if (!ensureChatServiceInitialized()) return; - if (conversationManager.getActiveConversation() == null) { - conversationManager.startNewConversation(); + Conversation activeConv = conversationManager.getActiveConversation(); + if (activeConv == null) { + activeConv = conversationManager.startNewConversation(); sidebarView.updateConversationsList(conversationManager.getConversations()); } @@ -392,31 +420,58 @@ private void handleSendAction() { AIChatMessage assistantDraft = AIChatMessage.create("Assistant", "", "assistant"); chatAreaView.startMessageStreaming(assistantDraft); - - coordinator.askQuestion( - text, - (token) -> SwingUtilities.invokeLater(() -> chatAreaView.enqueueStreamingToken(token)), - () -> SwingUtilities.invokeLater(() -> { - chatAreaView.pruneStreaming(() -> { - if (!assistantDraft.getContent().isEmpty()) { - conversationManager.addMessageToActive(assistantDraft); + + if (activeConv.isAgentConversation()) { + coordinator.askAgentQuestion( + text, + (token) -> SwingUtilities.invokeLater(() -> chatAreaView.enqueueStreamingToken(token)), + () -> SwingUtilities.invokeLater(() -> { + chatAreaView.pruneStreaming(() -> { + if (!assistantDraft.getContent().isEmpty()) { + conversationManager.addMessageToActive(assistantDraft); + sidebarView.updateConversationsList(conversationManager.getConversations()); + } + mainView.setProcessing(false); + }); + }), + (errorMessage) -> SwingUtilities.invokeLater(() -> { + AIChatMessage partialDraft = chatAreaView.salvageStreamingDraft(); + + if (partialDraft != null && !partialDraft.getContent().isEmpty()) { + conversationManager.addMessageToActive(partialDraft); sidebarView.updateConversationsList(conversationManager.getConversations()); } - mainView.setProcessing(false); - }); - }), - (errorMessage) -> SwingUtilities.invokeLater(() -> { - AIChatMessage partialDraft = chatAreaView.salvageStreamingDraft(); - if (partialDraft != null && !partialDraft.getContent().isEmpty()) { - conversationManager.addMessageToActive(partialDraft); - sidebarView.updateConversationsList(conversationManager.getConversations()); - } + addMessage("System Error", errorMessage, "error"); + mainView.setProcessing(false); + }) + ); + } else { + coordinator.askQuestion( + text, + (token) -> SwingUtilities.invokeLater(() -> chatAreaView.enqueueStreamingToken(token)), + () -> SwingUtilities.invokeLater(() -> { + chatAreaView.pruneStreaming(() -> { + if (!assistantDraft.getContent().isEmpty()) { + conversationManager.addMessageToActive(assistantDraft); + sidebarView.updateConversationsList(conversationManager.getConversations()); + } + mainView.setProcessing(false); + }); + }), + (errorMessage) -> SwingUtilities.invokeLater(() -> { + AIChatMessage partialDraft = chatAreaView.salvageStreamingDraft(); + + if (partialDraft != null && !partialDraft.getContent().isEmpty()) { + conversationManager.addMessageToActive(partialDraft); + sidebarView.updateConversationsList(conversationManager.getConversations()); + } - addMessage("System Error", errorMessage, "error"); - mainView.setProcessing(false); - }) - ); + addMessage("System Error", errorMessage, "error"); + mainView.setProcessing(false); + }) + ); + } } private void addMessage(String sender, String message, String type) { @@ -578,6 +633,15 @@ public void addItemsToContext(List itemsToAdd) { Conversation activeConversation = conversationManager.getActiveConversation(); + if (activeConversation != null && activeConversation.isAgentConversation()) { + JOptionPane.showMessageDialog( + mainView.getFrame(), + "Agent conversations do not support context files.", + "AI Assistant", + JOptionPane.INFORMATION_MESSAGE); + return; + } + if (activeConversation == null) { startNewConversationWithCurrentContext(itemsToAdd); return; diff --git a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java index 15300535fd..6c536580d8 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java +++ b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java @@ -13,6 +13,7 @@ public class Conversation { private List contextIds; private List chatHashes; private List messages; + private boolean isAgentConversation; public Conversation() { this.id = UUID.randomUUID().toString(); // Universally Unique Identifier @@ -23,6 +24,7 @@ public Conversation() { this.contextIds = new ArrayList<>(); this.chatHashes = new ArrayList<>(); this.messages = new ArrayList<>(); + this.isAgentConversation = false; } // Standard Getters and Setters @@ -37,17 +39,25 @@ public Conversation() { public long getLastModified() { return lastModified; } public void updateLastModified() { this.lastModified = System.currentTimeMillis(); } - public List getContextIds() { + public List getContextIds() { + if (isAgentConversation) return new ArrayList<>(); if (contextIds == null) contextIds = new ArrayList<>(); return contextIds; } - public void setContextIds(List contextIds) { this.contextIds = contextIds; } + public void setContextIds(List contextIds) { + if (isAgentConversation) return; + this.contextIds = contextIds; + } - public List getChatHashes() { + public List getChatHashes() { + if (isAgentConversation) return new ArrayList<>(); if (chatHashes == null) chatHashes = new ArrayList<>(); return chatHashes; } - public void setChatHashes(List chatHashes) { this.chatHashes = chatHashes; } + public void setChatHashes(List chatHashes) { + if (isAgentConversation) return; + this.chatHashes = chatHashes; + } public List getMessages() { if (messages == null) messages = new ArrayList<>(); @@ -55,6 +65,14 @@ public List getMessages() { } public void setMessages(List messages) { this.messages = messages; } + public boolean isAgentConversation() { + return isAgentConversation; + } + + public void setAgentConversation(boolean isAgentConversation) { + this.isAgentConversation = isAgentConversation; + } + /** * Returns true when this conversation already has a completed assistant reply. */ diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index 4934a60d3b..c8c44d5fcc 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -315,6 +315,12 @@ public JPanel getTasksPanel() { return tasksPanel; } + public void setContextPanelVisible(boolean visible) { + if (contextPanel != null) { + contextPanel.setVisible(visible); + } + } + /** * Exposes the context addition workflow to external UI components (e.g., IPED context menus). * Delegates all state evaluation and routing logic to the {@link AIAssistantController}. diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java index ab3715b003..ddbb1ac215 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java @@ -15,8 +15,10 @@ import javax.swing.JButton; import javax.swing.JLabel; import javax.swing.JList; +import javax.swing.JMenuItem; import javax.swing.JOptionPane; import javax.swing.JPanel; +import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.ListSelectionModel; @@ -42,6 +44,7 @@ public class SidebarPanel extends JPanel { public interface SidebarListener { void onConversationSelected(Conversation conversation); void onNewChatRequested(); + void onNewAgentChatRequested(); void onDeleteRequested(Conversation conversation); } @@ -65,13 +68,19 @@ private void configurePanelLayout() { } private void initComponents() { - // Initializes the "New Chat" button with styling and action listener + // Initializes the "New Chat" button with dropdown menu newChatButton = new JButton("+ New Chat"); newChatButton.setFont(newChatButton.getFont().deriveFont(Font.BOLD)); newChatButton.addActionListener(e -> { - if (listener != null) { - listener.onNewChatRequested(); - } + if (listener == null) return; + JPopupMenu menu = new JPopupMenu(); + JMenuItem newChatItem = new JMenuItem("New Chat"); + newChatItem.addActionListener(ev -> listener.onNewChatRequested()); + menu.add(newChatItem); + JMenuItem newAgentItem = new JMenuItem("New Agent Chat"); + newAgentItem.addActionListener(ev -> listener.onNewAgentChatRequested()); + menu.add(newAgentItem); + menu.show(newChatButton, 0, newChatButton.getHeight()); }); add(newChatButton, BorderLayout.NORTH); @@ -98,7 +107,11 @@ private void setupCellRenderer() { if (value instanceof Conversation) { Conversation conv = (Conversation) value; - JLabel textLabel = new JLabel(conv.getTitle()); + String displayText = conv.getTitle(); + if (conv.isAgentConversation()) { + displayText += " (Agent)"; + } + JLabel textLabel = new JLabel(displayText); textLabel.setFont(list.getFont()); textLabel.setForeground(isSelected ? list.getSelectionForeground() : list.getForeground()); From fa03eef190e8a63a4241291de5fe8ecefafbce38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Wed, 8 Jul 2026 16:11:21 -0300 Subject: [PATCH 119/143] add mcp in iped-app\resources\scripts\mcp\iped-mcp-server --- .../scripts/mcp/iped-mcp-server/.env | 17 + .../scripts/mcp/iped-mcp-server/.gitignore | 222 +++++++++++++ .../scripts/mcp/iped-mcp-server/README.md | 115 +++++++ .../mcp/iped-mcp-server/opencode.json.example | 39 +++ .../mcp/iped-mcp-server/pyproject.toml | 18 ++ .../mcp/iped-mcp-server/requirements.txt | 4 + .../mcp/iped-mcp-server/src/__init__.py | 0 .../mcp/iped-mcp-server/src/case_manager.py | 301 ++++++++++++++++++ .../scripts/mcp/iped-mcp-server/src/config.py | 95 ++++++ .../mcp/iped-mcp-server/src/jvm_bridge.py | 52 +++ .../scripts/mcp/iped-mcp-server/src/main.py | 79 +++++ .../mcp/iped-mcp-server/src/tools/__init__.py | 0 .../iped-mcp-server/src/tools/bookmarks.py | 24 ++ .../iped-mcp-server/src/tools/documents.py | 62 ++++ .../mcp/iped-mcp-server/src/tools/search.py | 40 +++ .../mcp/iped-mcp-server/src/tools/sources.py | 6 + 16 files changed, 1074 insertions(+) create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/.env create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/.gitignore create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/README.md create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/opencode.json.example create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/pyproject.toml create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/requirements.txt create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/src/__init__.py create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/src/jvm_bridge.py create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/__init__.py create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/bookmarks.py create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/search.py create mode 100644 iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/sources.py diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/.env b/iped-app/resources/scripts/mcp/iped-mcp-server/.env new file mode 100644 index 0000000000..1c2431b0f0 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/.env @@ -0,0 +1,17 @@ +# IPED MCP Server Configuration + +# Path to IPED installation folder (contains lib/, iped-engine*.jar, etc.) +IPED_HOME=C:\iped\IPED\target\release\iped-4.4.0-SNAPSHOT + +# Path to processed case folder (or .txt file listing multiple cases) +CASE_PATH=C:\iped\output\output002_859fc10df + +# Optional: Custom JAVA_HOME (defaults to JAVA_HOME env var) +JAVA_HOME=C:\Program Files\BellSoft\LibericaJDK-11-Full\ + +# JVM maximum heap size +JVM_MAX_HEAP=4g + +# Host and port for MCP server (SSE transport, optional) +MCP_HOST=127.0.0.1 +MCP_PORT=8100 diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/.gitignore b/iped-app/resources/scripts/mcp/iped-mcp-server/.gitignore new file mode 100644 index 0000000000..12541b3b47 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/.gitignore @@ -0,0 +1,222 @@ +opencode.json + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +#.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +.vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml \ No newline at end of file diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/README.md b/iped-app/resources/scripts/mcp/iped-mcp-server/README.md new file mode 100644 index 0000000000..fd8199a725 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/README.md @@ -0,0 +1,115 @@ +# IPED MCP Server + +Servidor MCP (Model Context Protocol) para o [IPED](https://github.com/sepinf-inc/IPED) — ferramenta forense digital da Polícia Federal Brasileira. + +Acessa os casos processados do IPED diretamente via Java (PyJnius), sem passar pela API REST. + +## Requisitos + +- **Python 3.10+** +- **Java JDK 11** com JavaFX (ex: Liberica JDK 11 Full) +- **IPED** instalado e um caso já processado + +## Instalação + +```bash +cd iped-mcp-server +pip install -r requirements.txt +``` + +## Configuração + +Edite o arquivo `.env`: + +```env +# Caminho da instalação do IPED (pasta que contém lib/, iped-engine.jar, etc.) +IPED_HOME=C:\IPED\iped-4.3.1 + +# Caminho do caso processado (pasta do caso ou .txt listando múltiplos casos) +CASE_PATH=C:\Cases\meu-caso-processado + +# Opcional: caminho do JDK (se diferente do JAVA_HOME do sistema) +JAVA_HOME=C:\Program Files\Java\jdk-11 + +# Memória máxima para a JVM +JVM_MAX_HEAP=4g +``` + +## Uso + +### Iniciar servidor (modo stdio — padrão para OpenCode) + +```bash +cd C:\iped\iped-mcp-server +python -m src.main +``` + +### Iniciar servidor (modo SSE — para testes HTTP) + +```bash +cd C:\iped\iped-mcp-server +python -m src.main sse +``` + +## Ferramentas MCP Disponíveis + +| Ferramenta | Descrição | +|------------|-----------| +| `list_sources()` | Lista todas as fontes (casos) abertas | +| `search(query, source_id?)` | Busca com sintaxe Lucene | +| `search_by_type(file_type, source_id?)` | Busca por extensão (pdf, jpg, etc.) | +| `search_by_name(pattern, source_id?)` | Busca por nome com wildcards | +| `get_document(source_id, doc_id)` | Metadados do documento | +| `get_document_content(source_id, doc_id)` | Conteúdo binário (base64) | +| `get_document_text(source_id, doc_id)` | Texto extraído pelo parser | +| `get_document_thumbnail(source_id, doc_id)` | Thumbnail (base64 JPEG) | +| `list_bookmarks()` | Lista nomes dos bookmarks | +| `get_bookmark(name)` | Documentos em um bookmark | + +## Integração com OpenCode + +Adicione ao seu `opencode.json`: + +```json +{ + "mcpServers": { + "iped": { + "command": "python", + "args": ["-m", "src.main"], + "cwd": "C:\\iped\\iped-mcp-server" + } + } +} +``` + +## Exemplos de Busca (sintaxe Lucene) + +```text +name:*.pdf → arquivos PDF +type:jpg → arquivos JPG +category:image → categoria imagem +created:[2023-01-01 TO 2023-12-31] → intervalo de data +content:"palavra-chave" → busca em texto completo +*.* → todos os itens +``` + +## Estrutura do Projeto + +``` +iped-mcp-server/ +├── .env # Configuração +├── pyproject.toml # Metadados do projeto +├── requirements.txt # Dependências Python +└── src/ + ├── __init__.py + ├── main.py # Ponto de entrada FastMCP + ├── config.py # Leitura de configuração + ├── jvm_bridge.py # Inicialização da JVM via PyJnius + ├── case_manager.py # Wrapper para IPEDSource/IPEDMultiSource + └── tools/ + ├── __init__.py + ├── sources.py # list_sources + ├── search.py # search, search_by_type, search_by_name + ├── documents.py # get_document, get_content, get_text, get_thumb + └── bookmarks.py # list_bookmarks, get_bookmark +``` diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/opencode.json.example b/iped-app/resources/scripts/mcp/iped-mcp-server/opencode.json.example new file mode 100644 index 0000000000..fcb18f83b7 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/opencode.json.example @@ -0,0 +1,39 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "iped": { + "type": "local", + "command": [ + "python", + "-m", + "src.main" + ], + "cwd": ".", + "enabled": true + } + }, + "model": "local/Qwen3.5-122B", + "small_model": "local/Qwen3.5-122B", + "enabled_providers": ["local"], + "share": "disabled", + "autoupdate": false, + "provider": { + "local": { + "npm": "@ai-sdk/openai-compatible", + "name": "Local", + "options": { + "baseURL": "http://10.61.86.107:8080/v1", + "apiKey": "INSERIR_CHAVE_AQUI" + }, + "models": { + "Qwen3.5-122B": { + "name": "Qwen3.5-122B", + "limit": { + "context": 200000, + "output": 32768 + } + } + } + } + } +} \ No newline at end of file diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/pyproject.toml b/iped-app/resources/scripts/mcp/iped-mcp-server/pyproject.toml new file mode 100644 index 0000000000..8364c28a24 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "iped-mcp-server" +version = "0.1.0" +description = "MCP server for IPED Digital Forensic Tool using PyJnius for direct Java access" +requires-python = ">=3.10" +dependencies = [ + "fastmcp>=0.5.0", + "pyjnius>=1.6.1", + "pydantic>=2.0", + "python-dotenv>=1.0", +] + +[project.scripts] +iped-mcp-server = "iped_mcp_server.main:main" + +[tool.ruff] +line-length = 120 +target-version = "py310" diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/requirements.txt b/iped-app/resources/scripts/mcp/iped-mcp-server/requirements.txt new file mode 100644 index 0000000000..9228a7f634 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/requirements.txt @@ -0,0 +1,4 @@ +fastmcp>=0.5.0 +pyjnius>=1.6.1 +pydantic>=2.0 +python-dotenv>=1.0 diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/__init__.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py new file mode 100644 index 0000000000..be05969d81 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py @@ -0,0 +1,301 @@ +import logging +from pathlib import Path +from typing import Optional + +from .jvm_bridge import get_class, cast_to +from .config import settings + +logger = logging.getLogger(__name__) + + +class IPEDCaseManager: + def __init__(self): + self._source = None + self._multi_source = None + self._is_multi = False + + @property + def source(self): + if self._source is None: + raise RuntimeError("Case not opened. Call open_case() first.") + return self._source + + @property + def multi_source(self): + if self._multi_source is None: + raise RuntimeError("Multi-source not opened. Call open_case() first.") + return self._multi_source + + @property + def is_multi(self) -> bool: + return self._is_multi + + def open_case(self, case_path: Optional[Path] = None) -> dict: + case_path = case_path or settings.case_path + case_file = get_class("java.io.File")(str(case_path)) + + IPEDSource = get_class("iped.engine.data.IPEDSource") + IPEDMultiSource = get_class("iped.engine.data.IPEDMultiSource") + + if IPEDSource.checkIfIsCaseFolder(case_file): + logger.info("Opening single case: %s", case_path) + self._source = IPEDSource(case_file) + self._is_multi = False + return {"type": "single", "path": str(case_path), "total_items": self._source.getTotalItems()} + else: + logger.info("Opening multi-source: %s", case_path) + self._multi_source = IPEDMultiSource(case_file) + self._is_multi = True + atomic_sources = self._multi_source.getAtomicSources() + return { + "type": "multi", + "path": str(case_path), + "source_count": atomic_sources.size(), + "total_items": self._multi_source.getTotalItems(), + } + + def close_case(self): + if self._source is not None: + try: + self._source.close() + except Exception as e: + logger.warning("Error closing source: %s", e) + self._source = None + if self._multi_source is not None: + try: + self._multi_source.close() + except Exception as e: + logger.warning("Error closing multi-source: %s", e) + self._multi_source = None + self._is_multi = False + + def list_sources(self) -> list[dict]: + if self._is_multi: + sources = self._multi_source.getAtomicSources() + result = [] + for i in range(sources.size()): + s = sources.get(i) + result.append({ + "source_id": i, + "path": str(s.getCaseDir().getAbsolutePath()), + "total_items": s.getTotalItems(), + }) + return result + else: + return [{ + "source_id": 0, + "path": str(self._source.getCaseDir().getAbsolutePath()), + "total_items": self._source.getTotalItems(), + }] + + def search(self, query: str, source_id: Optional[int] = None) -> dict: + IPEDSearcher = get_class("iped.engine.search.IPEDSearcher") + + if source_id is not None and self._is_multi: + atomic = self._multi_source.getAtomicSourceBySourceId(source_id) + searcher = IPEDSearcher(atomic, query) + searcher.setNoScoring(True) + java_result = searcher.search() + length = java_result.getLength() + ids = [java_result.getId(i) for i in range(length)] + return { + "source_id": source_id, + "total": length, + "ids": ids, + } + elif self._is_multi: + searcher = IPEDSearcher(self._multi_source, query) + searcher.setNoScoring(True) + java_result = searcher.multiSearch() + length = java_result.getLength() + items = [] + for i in range(length): + item_id = java_result.getItem(i) + items.append({ + "source_id": item_id.getSourceId(), + "id": item_id.getId(), + }) + return { + "total": length, + "items": items, + } + else: + searcher = IPEDSearcher(self._source, query) + searcher.setNoScoring(True) + java_result = searcher.search() + length = java_result.getLength() + ids = [java_result.getId(i) for i in range(length)] + return { + "source_id": 0, + "total": length, + "ids": ids, + } + + def get_item(self, item_id: int, source_id: int = 0) -> dict: + if self._is_multi: + ItemId = get_class("iped.engine.data.ItemId") + iid = ItemId(source_id, item_id) + item = self._multi_source.getItemByItemId(iid) + else: + item = self._source.getItemByID(item_id) + + if item is None: + raise ValueError(f"Item not found: source={source_id}, id={item_id}") + + if self._is_multi: + lucene_id = self._multi_source.getLuceneId(iid) + else: + lucene_id = self._source.getLuceneId(item_id) + + props = { + "id": item.getId(), + "source_id": source_id, + "lucene_id": lucene_id, + "name": item.getName() or "", + "path": item.getPath() or "", + "extension": item.getExt() or "", + "type_extension": item.getType() or "", + "length": item.getLength() or 0, + "hash": item.getHash() or "", + "is_dir": item.isDir(), + "is_deleted": item.isDeleted(), + "is_carved": item.isCarved(), + "is_subitem": item.isSubItem(), + "has_children": item.hasChildren(), + "has_preview": item.hasPreview(), + } + + try: + props["media_type"] = str(item.getMediaType()) if item.getMediaType() else "" + except Exception: + props["media_type"] = "" + + for date_field, method_name in [ + ("created", "getCreationDate"), + ("modified", "getModDate"), + ("accessed", "getAccessDate"), + ("changed", "getChangeDate"), + ]: + try: + method = getattr(item, method_name) + dt = method() + props[date_field] = str(dt) if dt else None + except Exception: + props[date_field] = None + + try: + labels = item.getLabels() + props["bookmarks"] = list(labels) if labels else [] + except Exception: + props["bookmarks"] = [] + + return props + + def get_item_content(self, item_id: int, source_id: int = 0) -> Optional[bytes]: + if self._is_multi: + ItemId = get_class("iped.engine.data.ItemId") + iid = ItemId(source_id, item_id) + item = self._multi_source.getItemByItemId(iid) + else: + item = self._source.getItemByID(item_id) + + if item is None or item.isDir(): + return None + + stream = item.getBufferedInputStream() + if stream is None: + return None + + try: + IOUtils = get_class("org.apache.commons.io.IOUtils") + data = IOUtils.toByteArray(cast_to("java.io.InputStream", stream)) + return bytes(data) + finally: + stream.close() + + def get_item_text(self, item_id: int, source_id: int = 0) -> Optional[str]: + if self._is_multi: + ItemId = get_class("iped.engine.data.ItemId") + iid = ItemId(source_id, item_id) + item = self._multi_source.getItemByItemId(iid) + else: + item = self._source.getItemByID(item_id) + + if item is None: + return None + + reader = item.getTextReader() + if reader is None: + return None + + try: + IOUtils = get_class("org.apache.commons.io.IOUtils") + return IOUtils.toString(cast_to("java.io.Reader", reader)) + finally: + reader.close() + + def get_item_thumbnail(self, item_id: int, source_id: int = 0) -> Optional[bytes]: + if self._is_multi: + ItemId = get_class("iped.engine.data.ItemId") + iid = ItemId(source_id, item_id) + item = self._multi_source.getItemByItemId(iid) + else: + item = self._source.getItemByID(item_id) + + if item is None or not item.hasPreview(): + return None + + thumb = item.getThumb() + return bytes(thumb) if thumb else None + + def list_bookmarks(self) -> list[str]: + if self._is_multi: + bm = self._multi_source.getMultiBookmarks() + bm_set = bm.getBookmarkSet() + return list(bm_set) if bm_set else [] + else: + bm = self._source.getBookmarks() + bm_map = bm.getBookmarkMap() + return list(bm_map.values()) if bm_map else [] + + def get_bookmark(self, name: str) -> list[dict]: + if self._is_multi: + bm = self._multi_source.getMultiBookmarks() + bm_set = bm.getBookmarkSet() + if name not in (list(bm_set) if bm_set else []): + raise ValueError(f"Bookmark not found: {name}") + + IPEDSearcher = get_class("iped.engine.search.IPEDSearcher") + searcher = IPEDSearcher(self._multi_source, "") + searcher.setNoScoring(True) + all_results = searcher.multiSearch() + + result = [] + for i in range(all_results.getLength()): + item_id = all_results.getItem(i) + if bm.hasBookmark(item_id, name): + result.append({ + "source_id": item_id.getSourceId(), + "id": item_id.getId(), + }) + return result + else: + bm = self._source.getBookmarks() + bm_id = bm.getBookmarkId(name) + if bm_id < 0: + raise ValueError(f"Bookmark not found: {name}") + + IPEDSearcher = get_class("iped.engine.search.IPEDSearcher") + searcher = IPEDSearcher(self._source, "") + searcher.setNoScoring(True) + all_results = searcher.search() + + result = [] + for i in range(all_results.getLength()): + item_id = all_results.getId(i) + if bm.hasBookmark(item_id, bm_id): + result.append({"source_id": 0, "id": item_id}) + return result + + +case_manager = IPEDCaseManager() diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py new file mode 100644 index 0000000000..279ddd8ea9 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py @@ -0,0 +1,95 @@ +import os +import re +from pathlib import Path +from dataclasses import dataclass, field +from dotenv import load_dotenv + +load_dotenv() + + +@dataclass +class Settings: + iped_home: Path = field(default_factory=lambda: Path(os.getenv("IPED_HOME", "") or ".")) + case_path: Path = field(default_factory=lambda: Path(os.getenv("CASE_PATH", "") or ".")) + java_home: str = field(default_factory=lambda: os.getenv("JAVA_HOME", os.environ.get("JAVA_HOME", ""))) + jvm_max_heap: str = field(default_factory=lambda: os.getenv("JVM_MAX_HEAP", "4g")) + mcp_host: str = field(default_factory=lambda: os.getenv("MCP_HOST", "127.0.0.1")) + mcp_port: int = field(default_factory=lambda: int(os.getenv("MCP_PORT", "8100"))) + + def validate(self) -> list[str]: + errors = [] + + iped_raw = os.getenv("IPED_HOME", "") + if not iped_raw: + errors.append( + "IPED_HOME is not set. Add 'IPED_HOME=' to .env or set the IPED_HOME environment variable." + ) + elif not self.iped_home.is_dir(): + errors.append( + f"IPED_HOME directory does not exist: {self.iped_home}. " + f"Check the path in .env or environment variable." + ) + + case_raw = os.getenv("CASE_PATH", "") + if not case_raw: + errors.append( + "CASE_PATH is not set. Add 'CASE_PATH=' to .env or set the CASE_PATH environment variable." + ) + elif not Path(case_raw).exists(): + errors.append( + f"CASE_PATH does not exist: {case_raw}. Check the path in .env or environment variable." + ) + + java = self.java_home + if not java: + errors.append( + "JAVA_HOME is not set. Add 'JAVA_HOME=' to .env or set the JAVA_HOME environment variable." + ) + else: + java_exe = Path(java) / "bin" / "java.exe" + if not java_exe.is_file(): + java_exe = Path(java) / "bin" / "java" + if not java_exe.is_file(): + errors.append( + f"java executable not found at \"{Path(java) / 'bin' / 'java.exe'}\". " + f"JAVA_HOME should point to a JDK or JRE installation root (not the bin/ subdirectory)." + ) + + heap = self.jvm_max_heap + if not re.fullmatch(r"\d+[kKmMgGtT]", heap): + errors.append( + f"Invalid JVM_MAX_HEAP value: '{heap}'. Expected a valid heap size like '4g', '1024m', '2g'." + ) + + return errors + + @property + def jvm_args(self) -> list[str]: + return [f"-Xmx{self.jvm_max_heap}", "-Djava.awt.headless=true"] + + @property + def iped_lib_jars(self) -> list[Path]: + lib_dir = self.iped_home / "lib" + if not lib_dir.is_dir(): + return [] + return sorted(lib_dir.glob("*.jar")) + + @property + def iped_module_jars(self) -> list[Path]: + mods = ["iped-api", "iped-engine", "iped-utils"] + jars = [] + for m in mods: + jar = self.iped_home / f"{m}.jar" + if jar.is_file(): + jars.append(jar) + else: + for p in self.iped_home.glob(f"{m}-*.jar"): + jars.append(p) + return jars + + @property + def classpath_jars(self) -> list[Path]: + return self.iped_module_jars + self.iped_lib_jars + + +settings = Settings() diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/jvm_bridge.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/jvm_bridge.py new file mode 100644 index 0000000000..d7aae2fead --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/jvm_bridge.py @@ -0,0 +1,52 @@ +import logging + +import jnius_config + +from .config import settings + +logger = logging.getLogger(__name__) + +_JVM_INITIALIZED = False + + +def ensure_jvm() -> None: + global _JVM_INITIALIZED + if _JVM_INITIALIZED: + return + + jars = settings.classpath_jars + if not jars: + lib_dir = settings.iped_home / "lib" + lib_exists = lib_dir.is_dir() + module_jars = list(settings.iped_home.glob("iped-*.jar")) + raise RuntimeError( + f"No IPED JARs found at {settings.iped_home}. " + f"lib/ directory exists: {lib_exists}, " + f"module jars found: {len(module_jars)}. " + "Ensure IPED_HOME points to a valid IPED installation " + "with lib/ directory and module JARs (iped-api, iped-engine, iped-utils)." + ) + + logger.info("Adding %d JARs to classpath", len(jars)) + + for arg in settings.jvm_args: + jnius_config.add_options(arg) + + for jar in jars: + jnius_config.add_classpath(str(jar)) + + _JVM_INITIALIZED = True + logger.info("JVM configuration ready") + + +def get_class(name: str): + ensure_jvm() + from jnius import autoclass + return autoclass(name) + + +def cast_to(name: str, obj): + ensure_jvm() + from jnius import cast + return cast(name, obj) + diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py new file mode 100644 index 0000000000..f5cae944f1 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py @@ -0,0 +1,79 @@ +import logging +import sys + +from fastmcp import FastMCP + +from .config import settings +from .jvm_bridge import ensure_jvm +from .case_manager import case_manager +from .tools import sources, search, documents, bookmarks + +logger = logging.getLogger(__name__) + +mcp = FastMCP( + "IPED MCP Server", + instructions="Direct Java access to IPED Digital Forensic Tool via PyJnius", +) + + +def register_tools(): + mcp.tool(name="list_sources", description="List all open case sources with their IDs, paths, and item counts.")(sources.list_sources) + + mcp.tool(name="search", description="Search items using a Lucene query string. Supports IPED's Lucene query syntax.")(search.search) + mcp.tool(name="search_by_type", description="Search items by file type extension (e.g., pdf, jpg, docx).")(search.search_by_type) + mcp.tool(name="search_by_name", description="Search items by name pattern using wildcards.")(search.search_by_name) + + mcp.tool(name="get_document", description="Get metadata/properties of a document by source ID and document ID.")(documents.get_document) + mcp.tool(name="get_document_content", description="Get the raw binary content of a document as base64.")(documents.get_document_content) + mcp.tool(name="get_document_text", description="Get the extracted/parsed text content of a document.")(documents.get_document_text) + mcp.tool(name="get_document_thumbnail", description="Get the thumbnail image of a document as base64 JPEG.")(documents.get_document_thumbnail) + + mcp.tool(name="list_bookmarks", description="List all bookmark names available in the currently open case.")(bookmarks.list_bookmarks) + mcp.tool(name="get_bookmark", description="Get all documents in a bookmark by bookmark name.")(bookmarks.get_bookmark) + + +def main(): + logging.basicConfig( + level=logging.INFO, + format="%(levelname)s - %(name)s - %(message)s", + stream=sys.stderr, + ) + + errors = settings.validate() + if errors: + for err in errors: + logger.error("Configuration error: %s", err) + logger.error( + "Fix the above errors in .env or set the corresponding environment variables, then restart." + ) + sys.exit(1) + + logger.info("Initializing JVM with classpath from %s", settings.iped_home) + try: + ensure_jvm() + except RuntimeError as e: + logger.error("Failed to initialize JVM: %s", e) + sys.exit(1) + + logger.info("Opening case at %s", settings.case_path) + try: + info = case_manager.open_case() + logger.info("Case opened: %s", info) + except Exception as e: + logger.error("Failed to open case: %s", e) + sys.exit(1) + + register_tools() + + transport = sys.argv[1] if len(sys.argv) > 1 else "stdio" + + if transport == "sse": + logger.info("Starting MCP server on %s:%d (SSE)", settings.mcp_host, settings.mcp_port) + mcp.run(transport="sse", host=settings.mcp_host, port=settings.mcp_port) + else: + logger.info("Starting MCP server (stdio)") + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/__init__.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/bookmarks.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/bookmarks.py new file mode 100644 index 0000000000..f6bc3ff81c --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/bookmarks.py @@ -0,0 +1,24 @@ +from ..case_manager import case_manager + + +async def list_bookmarks() -> list[str]: + """List all bookmark names available in the currently open case.""" + return case_manager.list_bookmarks() + + +async def get_bookmark(name: str) -> str: + """Get all documents in a bookmark by bookmark name. + + Returns a formatted list of source_id and doc_id pairs for each + document in the bookmark. + + Args: + name: The bookmark name to look up + """ + items = case_manager.get_bookmark(name) + if not items: + return f"Bookmark '{name}' is empty." + lines = [f"Bookmark: {name}", f"Total items: {len(items)}", ""] + for item in items: + lines.append(f" source_id={item['source_id']}, doc_id={item['id']}") + return "\n".join(lines) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py new file mode 100644 index 0000000000..43e7da944d --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py @@ -0,0 +1,62 @@ +import base64 +from typing import Optional + +from ..case_manager import case_manager + + +def _format_props(props: dict) -> str: + lines = [] + for k, v in props.items(): + if v is not None and v != "" and v != 0: + lines.append(f"{k}: {v}") + return "\n".join(lines) + + +async def get_document(source_id: int, doc_id: int) -> str: + """Get metadata/properties of a document by its source ID and document ID. + + Returns formatted metadata including name, path, extension, size, hash, + timestamps, and bookmark labels. + + Args: + source_id: Source ID (use 0 for single-case) + doc_id: Document ID within the source + """ + props = case_manager.get_item(doc_id, source_id) + return _format_props(props) + + +async def get_document_content(source_id: int, doc_id: int) -> Optional[str]: + """Get the raw binary content of a document as base64. + + Args: + source_id: Source ID (use 0 for single-case) + doc_id: Document ID within the source + """ + data = case_manager.get_item_content(doc_id, source_id) + if data is None: + return None + return base64.b64encode(data).decode("utf-8") + + +async def get_document_text(source_id: int, doc_id: int) -> Optional[str]: + """Get the extracted/parsed text content of a document. + + Args: + source_id: Source ID (use 0 for single-case) + doc_id: Document ID within the source + """ + return case_manager.get_item_text(doc_id, source_id) + + +async def get_document_thumbnail(source_id: int, doc_id: int) -> Optional[str]: + """Get the thumbnail image of a document as base64 JPEG. + + Args: + source_id: Source ID (use 0 for single-case) + doc_id: Document ID within the source + """ + data = case_manager.get_item_thumbnail(doc_id, source_id) + if data is None: + return None + return base64.b64encode(data).decode("utf-8") diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/search.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/search.py new file mode 100644 index 0000000000..5461215b74 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/search.py @@ -0,0 +1,40 @@ +from typing import Optional + +from ..case_manager import case_manager + + +async def search(query: str, source_id: Optional[int] = None) -> dict: + """Search items using a Lucene query string. + + Supports IPED's Lucene query syntax, e.g.: + - "name:*.pdf" - files with PDF extension + - "category:image" - image category + - "created:[2020-01-01 TO 2020-12-31]" - date range + - "content:\"keyword\"" - full-text search + - "*.*" - all items + + Args: + query: Lucene query string + source_id: Optional source ID to restrict search to a single source + """ + return case_manager.search(query, source_id) + + +async def search_by_type(file_type: str, source_id: Optional[int] = None) -> dict: + """Search items by file type extension (e.g., pdf, jpg, docx, xls). + + Args: + file_type: File extension to search for (without dot) + source_id: Optional source ID to restrict search + """ + return case_manager.search(f"type:{file_type}", source_id) + + +async def search_by_name(name_pattern: str, source_id: Optional[int] = None) -> dict: + """Search items by name pattern using wildcards. + + Args: + name_pattern: Name pattern with wildcards, e.g. "*.pdf" or "*report*" + source_id: Optional source ID to restrict search + """ + return case_manager.search(f"name:{name_pattern}", source_id) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/sources.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/sources.py new file mode 100644 index 0000000000..591c8dce46 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/sources.py @@ -0,0 +1,6 @@ +from ..case_manager import case_manager + + +async def list_sources() -> list[dict]: + """List all open case sources with their IDs, paths, and item counts.""" + return case_manager.list_sources() From e69108d0c40881cc72806927931232661cc481d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Wed, 8 Jul 2026 17:39:40 -0300 Subject: [PATCH 120/143] feat: implement OpenCodeAgentService for agent interaction and command execution --- .../iped/app/ui/ai/AIChatCoordinator.java | 27 +-- .../app/ui/ai/agent/OpenCodeAgentService.java | 154 ++++++++++++++++++ 2 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index eac48a82b3..790a34c6eb 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -14,6 +14,7 @@ import iped.app.ui.ai.context.ConversationManager; import iped.data.IItem; import iped.properties.ExtraProperties; +import iped.app.ui.ai.agent.OpenCodeAgentService; import java.util.ArrayList; import java.util.List; @@ -39,6 +40,7 @@ public class AIChatCoordinator { private List currentContextItemIds = new ArrayList<>(); private final List chatHistory = new ArrayList<>(); + private final OpenCodeAgentService agentService = new OpenCodeAgentService(); /** * Constructs a new coordinator. @@ -201,19 +203,7 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o * For now, echoes the question back to allow UI testing of the agent flow. */ public void askAgentQuestion(String question, Consumer uiCallback, Runnable onComplete, Consumer onError) { - new Thread(() -> { - try { - uiCallback.accept("**[Agent]:** Thinking...\n\n"); - chatHistory.add(new AIStreamChatRequest.AIMessage("user", question)); - chatHistory.add(new AIStreamChatRequest.AIMessage("assistant", question)); - uiCallback.accept(question); - uiCallback.accept("\n\n"); - } catch (Exception e) { - onError.accept("Agent error: " + e.getMessage()); - } finally { - onComplete.run(); - } - }).start(); + agentService.askAgentQuestion(question, uiCallback, onComplete, onError); } public void clearHistory() { @@ -222,6 +212,7 @@ public void clearHistory() { // Also clear chat currentChatHashes and currentContextItemIds currentChatHashes.clear(); currentContextItemIds.clear(); + this.agentService.clearHistory(); } /** @@ -248,6 +239,16 @@ public void loadHistoricalContext(List hashes, List itemIds, Li } } } + + // Restore agent history if needed + this.agentService.clearHistory(); + if (uiMessages != null) { + for (AIChatMessage msg : uiMessages) { + if ("user".equals(msg.getType()) || "assistant".equals(msg.getType())) { + this.agentService.getChatHistory().add(new AIStreamChatRequest.AIMessage(msg.getType(), msg.getContent())); + } + } + } } /** diff --git a/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java b/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java new file mode 100644 index 0000000000..fab28b6e81 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java @@ -0,0 +1,154 @@ +package iped.app.ui.ai.agent; + +import iped.app.ui.ai.backend.AIStreamChatRequest; + +import java.io.File; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Service to interact with the local OpenCode agent CLI. + */ +public class OpenCodeAgentService { + + private final List chatHistory = new ArrayList<>(); + + public void askAgentQuestion(String question, Consumer uiCallback, Runnable onComplete, Consumer onError) { + new Thread(() -> { + try { + uiCallback.accept("**[Agent]:** Executando opencode...\n\n"); + + // 1. Localizar o diretório do mcp-server dinamicamente + File mcpServerDir = findMcpServerDir(); + + // 2. Resolver caminho do executável do opencode + String opencodeCmd = resolveOpencodeCommand(); + + // 3. Construir o comando + List command = new ArrayList<>(); + boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); + if (isWindows && !opencodeCmd.endsWith(".cmd") && !opencodeCmd.endsWith(".bat") && !opencodeCmd.endsWith(".exe")) { + command.add("cmd.exe"); + command.add("/c"); + } + command.add(opencodeCmd); + command.add("run"); + command.add(question); + command.add("--auto"); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(mcpServerDir); + + // Configurar variável de ambiente OPENCODE_CONFIG caso o opencode.json exista + File configFile = new File(mcpServerDir, "opencode.json"); + if (configFile.exists()) { + pb.environment().put("OPENCODE_CONFIG", configFile.getAbsolutePath()); + } + + pb.redirectErrorStream(true); + Process process = pb.start(); + + // Fechar stdin do subprocesso para evitar que ele trave esperando entrada + process.getOutputStream().close(); + + // 4. Ler saída em tempo real em blocos de caracteres para suportar streaming + StringBuilder fullResponse = new StringBuilder(); + try (InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) { + char[] buffer = new char[1024]; + int charsRead; + while ((charsRead = reader.read(buffer)) != -1) { + String chunk = new String(buffer, 0, charsRead); + uiCallback.accept(chunk); + fullResponse.append(chunk); + } + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + String errMsg = "O processo opencode terminou com código de erro: " + exitCode; + uiCallback.accept("\n\n**[Erro do Agent]:** " + errMsg + "\n"); + onError.accept(errMsg); + } else { + // Salvar no histórico local da conversa + chatHistory.add(new AIStreamChatRequest.AIMessage("user", question)); + chatHistory.add(new AIStreamChatRequest.AIMessage("assistant", fullResponse.toString())); + } + + } catch (Exception e) { + String errMsg = "Erro no Agent: " + e.getMessage(); + uiCallback.accept("\n\n**[Erro do Agent]:** " + errMsg + "\n"); + onError.accept(errMsg); + } finally { + onComplete.run(); + } + }).start(); + } + + private File findMcpServerDir() { + String appRoot = null; + try { + appRoot = iped.engine.config.Configuration.getInstance().appRoot; + } catch (Throwable t) { + // A configuração pode não estar ativa/inicializada em testes ou ambientes específicos + } + + if (appRoot != null) { + // 1. Caminho de produção/instalação empacotada + File prodPath = new File(appRoot, "scripts/mcp/iped-mcp-server"); + if (prodPath.exists() && prodPath.isDirectory()) { + return prodPath; + } + + // 2. Caminho de desenvolvimento executado a partir da raiz do repositório + File devPath = new File(appRoot, "iped-app/resources/scripts/mcp/iped-mcp-server"); + if (devPath.exists() && devPath.isDirectory()) { + return devPath; + } + } + + // 3. Fallback para execução direta sem appRoot + File fallbackPath = new File("iped-app/resources/scripts/mcp/iped-mcp-server"); + if (fallbackPath.exists() && fallbackPath.isDirectory()) { + return fallbackPath; + } + + return new File("."); + } + + private String resolveOpencodeCommand() { + boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); + if (isWindows) { + // Tenta localizar no diretório AppData npm padrão do Windows + String appData = System.getenv("APPDATA"); + if (appData != null) { + File npmOpencode = new File(appData, "npm/opencode.cmd"); + if (npmOpencode.exists()) { + return npmOpencode.getAbsolutePath(); + } + } + + // Backup em USERPROFILE caso APPDATA falhe + String userProfile = System.getenv("USERPROFILE"); + if (userProfile != null) { + File npmOpencode = new File(userProfile, "AppData/Roaming/npm/opencode.cmd"); + if (npmOpencode.exists()) { + return npmOpencode.getAbsolutePath(); + } + } + } + + // Fallback para comando padrão global resolvido pelo OS + return "opencode"; + } + + public void clearHistory() { + this.chatHistory.clear(); + } + + public List getChatHistory() { + return chatHistory; + } +} From 521c599bbad2925e8a148311da2a086e0736a9c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Wed, 8 Jul 2026 18:11:19 -0300 Subject: [PATCH 121/143] refator persistence logic --- .../iped/app/ui/ai/AIChatCoordinator.java | 13 +-- .../ui/ai/context/ConversationManager.java | 5 +- .../ai/controller/AIAssistantController.java | 85 ++++++++++++------- .../app/ui/ai/model/AgentConversation.java | 22 +++++ .../iped/app/ui/ai/model/Conversation.java | 38 +-------- .../app/ui/ai/model/StandardConversation.java | 38 +++++++++ .../ui/ai/util/ConversationPersistence.java | 14 ++- 7 files changed, 136 insertions(+), 79 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/model/AgentConversation.java create mode 100644 iped-app/src/main/java/iped/app/ui/ai/model/StandardConversation.java diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 790a34c6eb..467f2f61ae 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -10,6 +10,7 @@ import iped.app.ui.ai.model.ContextFileEntry; import iped.app.ui.ai.model.AIChatMessage; import iped.app.ui.ai.model.Conversation; +import iped.app.ui.ai.model.StandardConversation; import iped.app.ui.ai.context.AIContextManager; import iped.app.ui.ai.context.ConversationManager; import iped.data.IItem; @@ -122,15 +123,15 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o // Update cache state currentContextItemIds = newContextIds; - // Save the backend state to the Conversation Manager so it persists Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); - if (activeConv != null) { - activeConv.setContextIds(new ArrayList<>(currentContextItemIds)); - activeConv.setChatHashes(new ArrayList<>(currentChatHashes)); - activeConv.updateLastModified(); + if (activeConv instanceof StandardConversation) { + StandardConversation std = (StandardConversation) activeConv; + std.setContextIds(new ArrayList<>(currentContextItemIds)); + std.setChatHashes(new ArrayList<>(currentChatHashes)); + std.updateLastModified(); // Save the hydrated object to disk - ConversationPersistence.saveConversation(activeConv); + ConversationPersistence.saveConversation(std); } // Update the flag diff --git a/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java index 13c6dd2a4a..489e7b5b0f 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java +++ b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java @@ -2,6 +2,8 @@ import iped.app.ui.ai.model.AIChatMessage; import iped.app.ui.ai.model.Conversation; +import iped.app.ui.ai.model.StandardConversation; +import iped.app.ui.ai.model.AgentConversation; import iped.app.ui.ai.util.ConversationPersistence; import java.util.ArrayList; @@ -69,8 +71,7 @@ public Conversation startNewConversation() { * Initializes a fresh conversation with the specified agent flag and sets it as active. */ public Conversation startNewConversation(boolean isAgent) { - Conversation newConv = new Conversation(); - newConv.setAgentConversation(isAgent); + Conversation newConv = isAgent ? new AgentConversation() : new StandardConversation(); setActiveConversation(newConv); return newConv; } diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java index 81a0afab6d..3546aefae8 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -30,6 +30,8 @@ import iped.app.ui.ai.context.ConversationManager; import iped.app.ui.ai.model.AIChatMessage; import iped.app.ui.ai.model.Conversation; +import iped.app.ui.ai.model.StandardConversation; +import iped.app.ui.ai.model.AgentConversation; import iped.app.ui.ai.util.ConversationPersistence; import iped.app.ui.ai.view.AIAssistantPanel; import iped.app.ui.ai.view.ChatAreaPanel; @@ -192,15 +194,16 @@ public void contextChanged(ContextChangeEvent event) { if (isSwitchingChats) return; // Abort if the system is switching chats Conversation activeConv = conversationManager.getActiveConversation(); - if (activeConv != null && !activeConv.isAgentConversation()) { + if (activeConv instanceof StandardConversation) { + StandardConversation stdConv = (StandardConversation) activeConv; List currentIds = contextManager.getContextFiles().stream() .map(IItem::getId) .collect(Collectors.toList()); - activeConv.setContextIds(currentIds); - activeConv.updateLastModified(); + stdConv.setContextIds(currentIds); + stdConv.updateLastModified(); - final Conversation convToSave = activeConv; + final Conversation convToSave = stdConv; new Thread(() -> ConversationPersistence.saveConversation(convToSave)).start(); } } @@ -276,11 +279,16 @@ private void loadConversation(Conversation conv) { isSwitchingChats = true; conversationManager.setActiveConversation(conv); mainView.setContextPanelVisible(!conv.isAgentConversation()); - + if (coordinator != null) { - coordinator.loadHistoricalContext(conv.getChatHashes(), conv.getContextIds(), conv.getMessages()); + if (conv instanceof StandardConversation) { + StandardConversation std = (StandardConversation) conv; + coordinator.loadHistoricalContext(std.getChatHashes(), std.getContextIds(), conv.getMessages()); + } else { + coordinator.loadHistoricalContext(null, null, conv.getMessages()); + } } - + if (conv.isAgentConversation()) { isSwitchingChats = false; if (chatAreaView != null) chatAreaView.forceDiscardStreaming(); @@ -294,32 +302,35 @@ private void loadConversation(Conversation conv) { new Thread(() -> { List restoredItems = new ArrayList<>(); try { - if (conv.getChatHashes() != null && !conv.getChatHashes().isEmpty()) { - for (String hash : conv.getChatHashes()) { - try { - IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); - MultiSearchResult result = searcher.multiSearch(); - if (result != null && result.getLength() > 0) { - IItemId qualifiedItemId = result.getItem(0); - IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); - if (item != null) restoredItems.add(item); + if (conv instanceof StandardConversation) { + StandardConversation std = (StandardConversation) conv; + if (std.getChatHashes() != null && !std.getChatHashes().isEmpty()) { + for (String hash : std.getChatHashes()) { + try { + IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "hash:" + hash); + MultiSearchResult result = searcher.multiSearch(); + if (result != null && result.getLength() > 0) { + IItemId qualifiedItemId = result.getItem(0); + IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); + if (item != null) restoredItems.add(item); + } + } catch (Exception e) { + System.err.println("Could not restore context item hash: " + hash); } - } catch (Exception e) { - System.err.println("Could not restore context item hash: " + hash); } - } - } else if (conv.getContextIds() != null && !conv.getContextIds().isEmpty()) { - for (Integer itemId : conv.getContextIds()) { - try { - IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "id:" + itemId); - MultiSearchResult result = searcher.multiSearch(); - if (result != null && result.getLength() > 0) { - IItemId qualifiedItemId = result.getItem(0); - IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); - if (item != null) restoredItems.add(item); + } else if (std.getContextIds() != null && !std.getContextIds().isEmpty()) { + for (Integer itemId : std.getContextIds()) { + try { + IPEDSearcher searcher = new IPEDSearcher(App.get().appCase, "id:" + itemId); + MultiSearchResult result = searcher.multiSearch(); + if (result != null && result.getLength() > 0) { + IItemId qualifiedItemId = result.getItem(0); + IItem item = App.get().appCase.getItemByItemId(qualifiedItemId); + if (item != null) restoredItems.add(item); + } + } catch (Exception e) { + System.err.println("Could not restore context item ID: " + itemId); } - } catch (Exception e) { - System.err.println("Could not restore context item ID: " + itemId); } } } @@ -335,7 +346,12 @@ private void loadConversation(Conversation conv) { .map(IItem::getId).collect(Collectors.toList()); if (coordinator != null) { - coordinator.loadHistoricalContext(conv.getChatHashes(), freshIds, conv.getMessages()); + if (conv instanceof StandardConversation) { + StandardConversation std = (StandardConversation) conv; + coordinator.loadHistoricalContext(std.getChatHashes(), freshIds, conv.getMessages()); + } else { + coordinator.loadHistoricalContext(null, freshIds, conv.getMessages()); + } } contextManager.addContextFiles(restoredItems); } @@ -381,8 +397,11 @@ public void startNewConversationWithCurrentContext(List pendingItems) { } } - newConversation.setContextIds(contextIds); - newConversation.setChatHashes(new ArrayList<>()); + if (newConversation instanceof StandardConversation) { + StandardConversation std = (StandardConversation) newConversation; + std.setContextIds(contextIds); + std.setChatHashes(new ArrayList<>()); + } newConversation.setMessages(new ArrayList<>()); newConversation.updateLastModified(); diff --git a/iped-app/src/main/java/iped/app/ui/ai/model/AgentConversation.java b/iped-app/src/main/java/iped/app/ui/ai/model/AgentConversation.java new file mode 100644 index 0000000000..11b940df81 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/model/AgentConversation.java @@ -0,0 +1,22 @@ +package iped.app.ui.ai.model; + +public class AgentConversation extends Conversation { + private String sessionId; + + public AgentConversation() { + super(); + } + + @Override + public boolean isAgentConversation() { + return true; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java index 6c536580d8..b1b8e2b43f 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java +++ b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java @@ -4,27 +4,21 @@ import java.util.List; import java.util.UUID; -public class Conversation { +public abstract class Conversation { private String id; private String title; private String status; // e.g. "active", "deleted" private long createdAt; private long lastModified; - private List contextIds; - private List chatHashes; private List messages; - private boolean isAgentConversation; public Conversation() { - this.id = UUID.randomUUID().toString(); // Universally Unique Identifier + this.id = UUID.randomUUID().toString(); // Universally Unique Identifier this.title = "New Conversation"; this.status = "active"; this.createdAt = System.currentTimeMillis(); this.lastModified = this.createdAt; - this.contextIds = new ArrayList<>(); - this.chatHashes = new ArrayList<>(); this.messages = new ArrayList<>(); - this.isAgentConversation = false; } // Standard Getters and Setters @@ -39,39 +33,13 @@ public Conversation() { public long getLastModified() { return lastModified; } public void updateLastModified() { this.lastModified = System.currentTimeMillis(); } - public List getContextIds() { - if (isAgentConversation) return new ArrayList<>(); - if (contextIds == null) contextIds = new ArrayList<>(); - return contextIds; - } - public void setContextIds(List contextIds) { - if (isAgentConversation) return; - this.contextIds = contextIds; - } - - public List getChatHashes() { - if (isAgentConversation) return new ArrayList<>(); - if (chatHashes == null) chatHashes = new ArrayList<>(); - return chatHashes; - } - public void setChatHashes(List chatHashes) { - if (isAgentConversation) return; - this.chatHashes = chatHashes; - } - public List getMessages() { if (messages == null) messages = new ArrayList<>(); return messages; } public void setMessages(List messages) { this.messages = messages; } - public boolean isAgentConversation() { - return isAgentConversation; - } - - public void setAgentConversation(boolean isAgentConversation) { - this.isAgentConversation = isAgentConversation; - } + public abstract boolean isAgentConversation(); /** * Returns true when this conversation already has a completed assistant reply. diff --git a/iped-app/src/main/java/iped/app/ui/ai/model/StandardConversation.java b/iped-app/src/main/java/iped/app/ui/ai/model/StandardConversation.java new file mode 100644 index 0000000000..84978c331d --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/model/StandardConversation.java @@ -0,0 +1,38 @@ +package iped.app.ui.ai.model; + +import java.util.ArrayList; +import java.util.List; + +public class StandardConversation extends Conversation { + private List contextIds; + private List chatHashes; + + public StandardConversation() { + super(); + this.contextIds = new ArrayList<>(); + this.chatHashes = new ArrayList<>(); + } + + @Override + public boolean isAgentConversation() { + return false; + } + + public List getContextIds() { + if (contextIds == null) contextIds = new ArrayList<>(); + return contextIds; + } + + public void setContextIds(List contextIds) { + this.contextIds = contextIds; + } + + public List getChatHashes() { + if (chatHashes == null) chatHashes = new ArrayList<>(); + return chatHashes; + } + + public void setChatHashes(List chatHashes) { + this.chatHashes = chatHashes; + } +} diff --git a/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java b/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java index 0bc3d194a8..29f2d007b9 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java +++ b/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java @@ -3,6 +3,8 @@ import iped.engine.data.IPEDMultiSource; import iped.app.ui.App; import iped.app.ui.ai.model.Conversation; +import iped.app.ui.ai.model.StandardConversation; +import iped.app.ui.ai.model.AgentConversation; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -75,7 +77,8 @@ public static void saveConversation(Conversation conversation) { File dir = getStorageDirectory(); if (dir == null || conversation == null) return; - File chatFile = new File(dir, "chat_" + conversation.getId() + ".json"); + String prefix = conversation.isAgentConversation() ? "agent_" : "chat_"; + File chatFile = new File(dir, prefix + conversation.getId() + ".json"); try (FileWriter writer = new FileWriter(chatFile)) { GSON.toJson(conversation, writer); @@ -93,11 +96,16 @@ public static List loadAllConversations() { if (dir == null || !dir.exists()) return conversations; - File[] files = dir.listFiles((d, name) -> name.startsWith("chat_") && name.endsWith(".json")); + File[] files = dir.listFiles((d, name) -> + (name.startsWith("chat_") || name.startsWith("agent_")) && name.endsWith(".json") + ); if (files != null) { for (File file : files) { try (FileReader reader = new FileReader(file)) { - Conversation conv = GSON.fromJson(reader, Conversation.class); + Class targetClass = file.getName().startsWith("agent_") + ? AgentConversation.class + : StandardConversation.class; + Conversation conv = GSON.fromJson(reader, targetClass); if (conv != null) { if (!"deleted".equals(conv.getStatus())) { conversations.add(conv); From 2d4661a3945cd887a214d8914d512a85a6c20e9a Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 9 Jul 2026 11:02:43 -0300 Subject: [PATCH 122/143] fix: correct mcp path resolution in agent conversation. - Enables local model initialization with MCP tools in agent mode - Resolves incorrect working directory preventing opencode.json loading --- .../main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java b/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java index fab28b6e81..d8a98d8202 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java +++ b/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java @@ -102,8 +102,8 @@ private File findMcpServerDir() { return prodPath; } - // 2. Caminho de desenvolvimento executado a partir da raiz do repositório - File devPath = new File(appRoot, "iped-app/resources/scripts/mcp/iped-mcp-server"); + // 2. Caminho de desenvolvimento + File devPath = new File(appRoot, "resources/scripts/mcp/iped-mcp-server"); if (devPath.exists() && devPath.isDirectory()) { return devPath; } From 98266296181ffacd56ff79e07ca035c4a2b9e376 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 9 Jul 2026 12:21:45 -0300 Subject: [PATCH 123/143] feat(agent): capture and persist opencode session ID - Add --format json flag to extract structured output from opencode - Parse session ID from JSON stream using regex pattern matching - Extract text content and tool calls using GSON JSON parser - Store session ID in AgentConversation and persist to disk --- .../iped/app/ui/ai/AIChatCoordinator.java | 20 +- .../app/ui/ai/agent/OpenCodeAgentService.java | 197 +++++++++++++++++- 2 files changed, 205 insertions(+), 12 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index 467f2f61ae..b904425dee 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -11,6 +11,7 @@ import iped.app.ui.ai.model.AIChatMessage; import iped.app.ui.ai.model.Conversation; import iped.app.ui.ai.model.StandardConversation; +import iped.app.ui.ai.model.AgentConversation; import iped.app.ui.ai.context.AIContextManager; import iped.app.ui.ai.context.ConversationManager; import iped.data.IItem; @@ -198,13 +199,22 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o /** - * Placeholder for agent-style chat. No context files, no chat hashes, no initialization. - * The actual connection to a local agent (e.g. opencode) will be implemented later. - *

- * For now, echoes the question back to allow UI testing of the agent flow. + * Agent-style chat with session persistence. + * Captures and reuses opencode session ID to maintain conversation context. */ public void askAgentQuestion(String question, Consumer uiCallback, Runnable onComplete, Consumer onError) { - agentService.askAgentQuestion(question, uiCallback, onComplete, onError); + Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); + + // Callback to capture session ID when found + Consumer onSessionIdFound = sessionId -> { + if (activeConv instanceof AgentConversation) { + AgentConversation agentConv = (AgentConversation) activeConv; + agentConv.setSessionId(sessionId); + ConversationPersistence.saveConversation(agentConv); + } + }; + + agentService.askAgentQuestion(question, uiCallback, onSessionIdFound, onComplete, onError); } public void clearHistory() { diff --git a/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java b/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java index d8a98d8202..03884f9124 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java +++ b/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java @@ -1,5 +1,7 @@ package iped.app.ui.ai.agent; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import iped.app.ui.ai.backend.AIStreamChatRequest; import java.io.File; @@ -8,6 +10,8 @@ import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Service to interact with the local OpenCode agent CLI. @@ -15,8 +19,9 @@ public class OpenCodeAgentService { private final List chatHistory = new ArrayList<>(); + private static final Pattern SESSION_ID_PATTERN = Pattern.compile("\"sessionID\":\"(ses_[^\"]+)\""); - public void askAgentQuestion(String question, Consumer uiCallback, Runnable onComplete, Consumer onError) { + public void askAgentQuestion(String question, Consumer uiCallback, Consumer onSessionIdFound, Runnable onComplete, Consumer onError) { new Thread(() -> { try { uiCallback.accept("**[Agent]:** Executando opencode...\n\n"); @@ -38,6 +43,8 @@ public void askAgentQuestion(String question, Consumer uiCallback, Runna command.add("run"); command.add(question); command.add("--auto"); + command.add("--format"); + command.add("json"); ProcessBuilder pb = new ProcessBuilder(command); pb.directory(mcpServerDir); @@ -54,15 +61,38 @@ public void askAgentQuestion(String question, Consumer uiCallback, Runna // Fechar stdin do subprocesso para evitar que ele trave esperando entrada process.getOutputStream().close(); - // 4. Ler saída em tempo real em blocos de caracteres para suportar streaming + // 4. Ler saída em tempo real, extrair session ID e converter JSON para texto StringBuilder fullResponse = new StringBuilder(); + StringBuilder buffer = new StringBuilder(); + boolean sessionIdExtracted = false; + try (InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) { - char[] buffer = new char[1024]; + char[] charBuffer = new char[1024]; int charsRead; - while ((charsRead = reader.read(buffer)) != -1) { - String chunk = new String(buffer, 0, charsRead); - uiCallback.accept(chunk); - fullResponse.append(chunk); + while ((charsRead = reader.read(charBuffer)) != -1) { + String chunk = new String(charBuffer, 0, charsRead); + buffer.append(chunk); + + // Tentar extrair session ID do buffer acumulado + if (!sessionIdExtracted) { + String bufferContent = buffer.toString(); + Matcher matcher = SESSION_ID_PATTERN.matcher(bufferContent); + if (matcher.find()) { + String sessionId = matcher.group(1); + onSessionIdFound.accept(sessionId); + sessionIdExtracted = true; + } + } + + // Processar JSON Lines e converter para texto + String textContent = extractTextFromJsonLines(buffer.toString()); + if (!textContent.isEmpty()) { + uiCallback.accept(textContent); + fullResponse.append(textContent); + } + + // Limpar buffer após processamento bem-sucedido + buffer.setLength(0); } } @@ -87,6 +117,159 @@ public void askAgentQuestion(String question, Consumer uiCallback, Runna }).start(); } + /** + * Extrai conteúdo legível do formato JSON Lines do opencode. + * Mantém o mesmo formato visual: model info, tool calls, e texto. + */ + private String extractTextFromJsonLines(String jsonLines) { + StringBuilder output = new StringBuilder(); + String[] lines = jsonLines.split("\n"); + + for (String line : lines) { + if (line.trim().isEmpty()) continue; + + try { + JsonObject json = JsonParser.parseString(line).getAsJsonObject(); + String type = json.has("type") ? json.get("type").getAsString() : null; + + if ("step_start".equals(type)) { + // step_start não produz texto visível diretamente + // O modelo aparece no texto normal + } else if ("text".equals(type)) { + // Extrair conteúdo de texto + String text = extractTextContent(json); + if (text != null && !text.isEmpty()) { + output.append(text); + } + } else if ("tool_use".equals(type)) { + // Extrair tool calls mantendo o formato original + String toolInfo = extractToolInfo(json); + if (toolInfo != null && !toolInfo.isEmpty()) { + output.append(toolInfo).append("\n"); + } + } else if ("step_finish".equals(type)) { + // Ignorar step_finish + } else { + // Para outros tipos, tentar extrair texto diretamente + String text = extractTextContent(json); + if (text != null && !text.isEmpty()) { + output.append(text); + } + } + } catch (Exception e) { + // Se falhar na parsing, ignora esta linha + // Isso pode acontecer com chunks parciais + } + } + + return output.toString(); + } + + /** + * Extrai o conteúdo de texto do JSON, lidando com campos aninhados. + */ + private String extractTextContent(JsonObject json) { + // Tentar extrair de "text" field diretamente + if (json.has("text")) { + return unescapeJsonString(json.get("text").getAsString()); + } + + // Tentar extrair de "part" -> "text" + if (json.has("part")) { + JsonObject part = json.getAsJsonObject("part"); + if (part.has("text")) { + return unescapeJsonString(part.get("text").getAsString()); + } + } + + // Tentar extrair de "parts" array + if (json.has("parts")) { + var parts = json.getAsJsonArray("parts"); + for (var i = 0; i < parts.size(); i++) { + JsonObject part = parts.get(i).getAsJsonObject(); + if (part.has("text")) { + return unescapeJsonString(part.get("text").getAsString()); + } + } + } + + // Tentar extrair de "content" field + if (json.has("content")) { + return unescapeJsonString(json.get("content").getAsString()); + } + + // Tentar extrair de "part" -> "content" + if (json.has("part")) { + JsonObject part = json.getAsJsonObject("part"); + if (part.has("content")) { + return unescapeJsonString(part.get("content").getAsString()); + } + } + + return null; + } + + /** + * Extrai informações de tool calls do JSON. + * Formato: "? toolName {arguments}" + */ + private String extractToolInfo(JsonObject json) { + // Extrair do "part" object + if (!json.has("part")) { + return null; + } + + JsonObject part = json.getAsJsonObject("part"); + String toolName = null; + String arguments = null; + + // Tentar extrair tool name de diferentes campos + if (part.has("tool")) { + toolName = part.get("tool").getAsString(); + } else if (part.has("toolName")) { + toolName = part.get("toolName").getAsString(); + } + + // Tentar extrair arguments + if (part.has("state")) { + JsonObject state = part.getAsJsonObject("state"); + if (state.has("input")) { + arguments = state.get("input").toString(); + } + } + + if (toolName == null) { + return null; + } + + // Formatar output no estilo original + StringBuilder result = new StringBuilder(); + result.append("? ").append(toolName); + + if (arguments != null) { + // Format arguments nicely - remove outer quotes if JSON string + String cleanArgs = arguments; + if (cleanArgs.startsWith("\"") && cleanArgs.endsWith("\"")) { + cleanArgs = cleanArgs.substring(1, cleanArgs.length() - 1); + } + result.append(" ").append(cleanArgs); + } + + return result.toString(); + } + + /** + * Remove escaping de strings JSON. + */ + private String unescapeJsonString(String text) { + if (text == null) return ""; + return text.replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + .replace("\\\"", "\"") + .replace("\\\\", "\\"); + } + private File findMcpServerDir() { String appRoot = null; try { From 97ba3d6f69ae0af152fb47abc668d42b638e81ef Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Thu, 9 Jul 2026 12:57:32 -0300 Subject: [PATCH 124/143] feat(agent): reuse opencode session across messages in the same conversation - Add sessionId parameter to OpenCodeAgentService.askAgentQuestion() - Pass --session flag to opencode when session ID is available - Extract session ID from AgentConversation before each request - Maintain conversation context across IPED restarts - Translate comments to english --- .../iped/app/ui/ai/AIChatCoordinator.java | 14 ++- .../app/ui/ai/agent/OpenCodeAgentService.java | 110 +++++++++--------- 2 files changed, 68 insertions(+), 56 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java index b904425dee..57704e7b51 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -205,16 +205,22 @@ public void askQuestion(String question, Consumer uiCallback, Runnable o public void askAgentQuestion(String question, Consumer uiCallback, Runnable onComplete, Consumer onError) { Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); - // Callback to capture session ID when found - Consumer onSessionIdFound = sessionId -> { + // Get existing session ID if available + String sessionId = null; + if (activeConv instanceof AgentConversation) { + sessionId = ((AgentConversation) activeConv).getSessionId(); + } + + // Callback to capture session ID when found (for new conversations) + Consumer onSessionIdFound = newSessionId -> { if (activeConv instanceof AgentConversation) { AgentConversation agentConv = (AgentConversation) activeConv; - agentConv.setSessionId(sessionId); + agentConv.setSessionId(newSessionId); ConversationPersistence.saveConversation(agentConv); } }; - agentService.askAgentQuestion(question, uiCallback, onSessionIdFound, onComplete, onError); + agentService.askAgentQuestion(question, uiCallback, onSessionIdFound, sessionId, onComplete, onError); } public void clearHistory() { diff --git a/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java b/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java index 03884f9124..712b6b11fd 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java +++ b/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java @@ -21,18 +21,18 @@ public class OpenCodeAgentService { private final List chatHistory = new ArrayList<>(); private static final Pattern SESSION_ID_PATTERN = Pattern.compile("\"sessionID\":\"(ses_[^\"]+)\""); - public void askAgentQuestion(String question, Consumer uiCallback, Consumer onSessionIdFound, Runnable onComplete, Consumer onError) { + public void askAgentQuestion(String question, Consumer uiCallback, Consumer onSessionIdFound, String sessionId, Runnable onComplete, Consumer onError) { new Thread(() -> { try { uiCallback.accept("**[Agent]:** Executando opencode...\n\n"); - // 1. Localizar o diretório do mcp-server dinamicamente + // 1. Locate the mcp-server directory dynamically File mcpServerDir = findMcpServerDir(); - // 2. Resolver caminho do executável do opencode + // 2. Resolve the opencode executable path String opencodeCmd = resolveOpencodeCommand(); - // 3. Construir o comando + // 3. Build the command List command = new ArrayList<>(); boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); if (isWindows && !opencodeCmd.endsWith(".cmd") && !opencodeCmd.endsWith(".bat") && !opencodeCmd.endsWith(".exe")) { @@ -45,11 +45,17 @@ public void askAgentQuestion(String question, Consumer uiCallback, Consu command.add("--auto"); command.add("--format"); command.add("json"); + + // Add session ID if available to maintain context + if (sessionId != null && !sessionId.isEmpty()) { + command.add("--session"); + command.add(sessionId); + } ProcessBuilder pb = new ProcessBuilder(command); pb.directory(mcpServerDir); - // Configurar variável de ambiente OPENCODE_CONFIG caso o opencode.json exista + // Set OPENCODE_CONFIG environment variable if opencode.json exists File configFile = new File(mcpServerDir, "opencode.json"); if (configFile.exists()) { pb.environment().put("OPENCODE_CONFIG", configFile.getAbsolutePath()); @@ -58,10 +64,10 @@ public void askAgentQuestion(String question, Consumer uiCallback, Consu pb.redirectErrorStream(true); Process process = pb.start(); - // Fechar stdin do subprocesso para evitar que ele trave esperando entrada + // Close subprocess stdin to prevent it from blocking waiting for input process.getOutputStream().close(); - // 4. Ler saída em tempo real, extrair session ID e converter JSON para texto + // 4. Read output in real-time, extract session ID and convert JSON to text StringBuilder fullResponse = new StringBuilder(); StringBuilder buffer = new StringBuilder(); boolean sessionIdExtracted = false; @@ -73,43 +79,43 @@ public void askAgentQuestion(String question, Consumer uiCallback, Consu String chunk = new String(charBuffer, 0, charsRead); buffer.append(chunk); - // Tentar extrair session ID do buffer acumulado + // Try to extract session ID from accumulated buffer if (!sessionIdExtracted) { String bufferContent = buffer.toString(); Matcher matcher = SESSION_ID_PATTERN.matcher(bufferContent); if (matcher.find()) { - String sessionId = matcher.group(1); - onSessionIdFound.accept(sessionId); + String extractedSessionId = matcher.group(1); + onSessionIdFound.accept(extractedSessionId); sessionIdExtracted = true; } } - // Processar JSON Lines e converter para texto + // Process JSON Lines and convert to text String textContent = extractTextFromJsonLines(buffer.toString()); if (!textContent.isEmpty()) { uiCallback.accept(textContent); fullResponse.append(textContent); } - // Limpar buffer após processamento bem-sucedido + // Clear buffer after successful processing buffer.setLength(0); } } int exitCode = process.waitFor(); if (exitCode != 0) { - String errMsg = "O processo opencode terminou com código de erro: " + exitCode; - uiCallback.accept("\n\n**[Erro do Agent]:** " + errMsg + "\n"); + String errMsg = "The opencode process terminated with error code: " + exitCode; + uiCallback.accept("\n\n**[Agent Error]:** " + errMsg + "\n"); onError.accept(errMsg); } else { - // Salvar no histórico local da conversa + // Save to local conversation history chatHistory.add(new AIStreamChatRequest.AIMessage("user", question)); chatHistory.add(new AIStreamChatRequest.AIMessage("assistant", fullResponse.toString())); } } catch (Exception e) { - String errMsg = "Erro no Agent: " + e.getMessage(); - uiCallback.accept("\n\n**[Erro do Agent]:** " + errMsg + "\n"); + String errMsg = "Agent Error: " + e.getMessage(); + uiCallback.accept("\n\n**[Agent Error]:** " + errMsg + "\n"); onError.accept(errMsg); } finally { onComplete.run(); @@ -118,8 +124,8 @@ public void askAgentQuestion(String question, Consumer uiCallback, Consu } /** - * Extrai conteúdo legível do formato JSON Lines do opencode. - * Mantém o mesmo formato visual: model info, tool calls, e texto. + * Extracts readable content from the OpenCode JSON Lines format. + * Preserves the same visual format: model info, tool calls, and text. */ private String extractTextFromJsonLines(String jsonLines) { StringBuilder output = new StringBuilder(); @@ -133,32 +139,32 @@ private String extractTextFromJsonLines(String jsonLines) { String type = json.has("type") ? json.get("type").getAsString() : null; if ("step_start".equals(type)) { - // step_start não produz texto visível diretamente - // O modelo aparece no texto normal + // step_start does not produce visible text directly + // The model appears in the regular text } else if ("text".equals(type)) { - // Extrair conteúdo de texto + // Extract text's content String text = extractTextContent(json); if (text != null && !text.isEmpty()) { output.append(text); } } else if ("tool_use".equals(type)) { - // Extrair tool calls mantendo o formato original + // Extract tool calls while preserving the original format String toolInfo = extractToolInfo(json); if (toolInfo != null && !toolInfo.isEmpty()) { output.append(toolInfo).append("\n"); } } else if ("step_finish".equals(type)) { - // Ignorar step_finish + // Ignore step_finish } else { - // Para outros tipos, tentar extrair texto diretamente + // For other types, try to extract the text directly String text = extractTextContent(json); if (text != null && !text.isEmpty()) { output.append(text); } } } catch (Exception e) { - // Se falhar na parsing, ignora esta linha - // Isso pode acontecer com chunks parciais + // If parsing fails, ignore this line + // This can happen with partial chunks } } @@ -166,15 +172,15 @@ private String extractTextFromJsonLines(String jsonLines) { } /** - * Extrai o conteúdo de texto do JSON, lidando com campos aninhados. + * Extracts text content from the JSON, handling nested fields. */ private String extractTextContent(JsonObject json) { - // Tentar extrair de "text" field diretamente + // Try to extract directly from the "text" field if (json.has("text")) { return unescapeJsonString(json.get("text").getAsString()); } - // Tentar extrair de "part" -> "text" + // Try to extract from "part" -> "text" if (json.has("part")) { JsonObject part = json.getAsJsonObject("part"); if (part.has("text")) { @@ -182,7 +188,7 @@ private String extractTextContent(JsonObject json) { } } - // Tentar extrair de "parts" array + // Try to extract from the "parts" array if (json.has("parts")) { var parts = json.getAsJsonArray("parts"); for (var i = 0; i < parts.size(); i++) { @@ -193,12 +199,12 @@ private String extractTextContent(JsonObject json) { } } - // Tentar extrair de "content" field + // Try to extract from the "content" field if (json.has("content")) { return unescapeJsonString(json.get("content").getAsString()); } - // Tentar extrair de "part" -> "content" + // Try to extract from "part" -> "content" if (json.has("part")) { JsonObject part = json.getAsJsonObject("part"); if (part.has("content")) { @@ -210,11 +216,11 @@ private String extractTextContent(JsonObject json) { } /** - * Extrai informações de tool calls do JSON. - * Formato: "? toolName {arguments}" + * Extracts tool call information from the JSON. + * Format: "? toolName {arguments}" */ private String extractToolInfo(JsonObject json) { - // Extrair do "part" object + // Extract from the "part" object if (!json.has("part")) { return null; } @@ -223,14 +229,14 @@ private String extractToolInfo(JsonObject json) { String toolName = null; String arguments = null; - // Tentar extrair tool name de diferentes campos + // Try to extract the tool name from different fields if (part.has("tool")) { toolName = part.get("tool").getAsString(); } else if (part.has("toolName")) { toolName = part.get("toolName").getAsString(); } - // Tentar extrair arguments + // Try to extract the arguments if (part.has("state")) { JsonObject state = part.getAsJsonObject("state"); if (state.has("input")) { @@ -242,7 +248,7 @@ private String extractToolInfo(JsonObject json) { return null; } - // Formatar output no estilo original + // Format the output in the original style StringBuilder result = new StringBuilder(); result.append("? ").append(toolName); @@ -259,15 +265,15 @@ private String extractToolInfo(JsonObject json) { } /** - * Remove escaping de strings JSON. + * Removes JSON string escaping. */ private String unescapeJsonString(String text) { if (text == null) return ""; return text.replace("\\n", "\n") - .replace("\\r", "\r") - .replace("\\t", "\t") - .replace("\\\"", "\"") - .replace("\\\\", "\\"); + .replace("\\r", "\r") + .replace("\\t", "\t") + .replace("\\\"", "\"") + .replace("\\\\", "\\"); } private File findMcpServerDir() { @@ -275,24 +281,24 @@ private File findMcpServerDir() { try { appRoot = iped.engine.config.Configuration.getInstance().appRoot; } catch (Throwable t) { - // A configuração pode não estar ativa/inicializada em testes ou ambientes específicos + // Configuration may not be active/initialized in tests or specific environments } if (appRoot != null) { - // 1. Caminho de produção/instalação empacotada + // 1. Production/packaged installation path File prodPath = new File(appRoot, "scripts/mcp/iped-mcp-server"); if (prodPath.exists() && prodPath.isDirectory()) { return prodPath; } - // 2. Caminho de desenvolvimento + // 2. Development path File devPath = new File(appRoot, "resources/scripts/mcp/iped-mcp-server"); if (devPath.exists() && devPath.isDirectory()) { return devPath; } } - // 3. Fallback para execução direta sem appRoot + // 3. Fallback for direct execution without appRoot File fallbackPath = new File("iped-app/resources/scripts/mcp/iped-mcp-server"); if (fallbackPath.exists() && fallbackPath.isDirectory()) { return fallbackPath; @@ -304,7 +310,7 @@ private File findMcpServerDir() { private String resolveOpencodeCommand() { boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); if (isWindows) { - // Tenta localizar no diretório AppData npm padrão do Windows + // Try to locate the standard Windows AppData npm directory String appData = System.getenv("APPDATA"); if (appData != null) { File npmOpencode = new File(appData, "npm/opencode.cmd"); @@ -313,7 +319,7 @@ private String resolveOpencodeCommand() { } } - // Backup em USERPROFILE caso APPDATA falhe + // Fallback to USERPROFILE if APPDATA is unavailable String userProfile = System.getenv("USERPROFILE"); if (userProfile != null) { File npmOpencode = new File(userProfile, "AppData/Roaming/npm/opencode.cmd"); @@ -323,7 +329,7 @@ private String resolveOpencodeCommand() { } } - // Fallback para comando padrão global resolvido pelo OS + // Fallback to the default global command resolved by the OS return "opencode"; } @@ -334,4 +340,4 @@ public void clearHistory() { public List getChatHistory() { return chatHistory; } -} +} \ No newline at end of file From 15201c3647ebf356f1ea728a9da5d8782ebea1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 9 Jul 2026 16:24:06 -0300 Subject: [PATCH 125/143] refactor: improve JSON processing in askAgentQuestion and update tool info format --- .../app/ui/ai/agent/OpenCodeAgentService.java | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java b/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java index 712b6b11fd..b6bda1d58c 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java +++ b/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java @@ -90,15 +90,22 @@ public void askAgentQuestion(String question, Consumer uiCallback, Consu } } - // Process JSON Lines and convert to text - String textContent = extractTextFromJsonLines(buffer.toString()); - if (!textContent.isEmpty()) { - uiCallback.accept(textContent); - fullResponse.append(textContent); + // Process only complete JSON Lines and convert to text + int lastNewLine = buffer.lastIndexOf("\n"); + if (lastNewLine != -1) { + String completeLines = buffer.substring(0, lastNewLine + 1); + String remaining = buffer.substring(lastNewLine + 1); + + String textContent = extractTextFromJsonLines(completeLines); + if (!textContent.isEmpty()) { + uiCallback.accept(textContent); + fullResponse.append(textContent); + } + + // Keep the incomplete part in the buffer + buffer.setLength(0); + buffer.append(remaining); } - - // Clear buffer after successful processing - buffer.setLength(0); } } @@ -250,7 +257,7 @@ private String extractToolInfo(JsonObject json) { // Format the output in the original style StringBuilder result = new StringBuilder(); - result.append("? ").append(toolName); + result.append("[Agente]: ").append(toolName); if (arguments != null) { // Format arguments nicely - remove outer quotes if JSON string From 674e3613b274001cb3e77f9ebb79098bf9e7e627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 9 Jul 2026 16:37:38 -0300 Subject: [PATCH 126/143] feat: add read tool to retrieve document metadata and text content fix: enhance search functionality with auto-escaping for IPED fields refactor: improve metadata formatting in document properties --- .../mcp/iped-mcp-server/src/case_manager.py | 111 ++++++++++++------ .../scripts/mcp/iped-mcp-server/src/main.py | 1 + .../iped-mcp-server/src/tools/documents.py | 29 ++++- 3 files changed, 103 insertions(+), 38 deletions(-) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py index be05969d81..0faeb43829 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py @@ -89,46 +89,70 @@ def list_sources(self) -> list[dict]: }] def search(self, query: str, source_id: Optional[int] = None) -> dict: + import re + + # Auto-escape unescaped colons in known IPED fields (e.g. Communication:From -> Communication\:From) + fields = [ + "Communication:From", + "Communication:To", + "Communication:Direction", + "Communication:Date", + "Communication:Participants" + ] + escaped_query = query + for f in fields: + pattern = r"(? dict: @@ -189,6 +213,19 @@ def get_item(self, item_id: int, source_id: int = 0) -> dict: except Exception: props["bookmarks"] = [] + try: + metadata = item.getMetadata() + if metadata is not None: + meta_dict = {} + for name in metadata.names(): + values = metadata.getValues(name) + if values: + val_list = [str(v) for v in values] + meta_dict[str(name)] = val_list if len(val_list) > 1 else val_list[0] + props["metadata"] = meta_dict + except Exception as e: + logger.warning("Error getting item metadata: %s", e) + return props def get_item_content(self, item_id: int, source_id: int = 0) -> Optional[bytes]: diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py index f5cae944f1..d8f65a5fca 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py @@ -27,6 +27,7 @@ def register_tools(): mcp.tool(name="get_document_content", description="Get the raw binary content of a document as base64.")(documents.get_document_content) mcp.tool(name="get_document_text", description="Get the extracted/parsed text content of a document.")(documents.get_document_text) mcp.tool(name="get_document_thumbnail", description="Get the thumbnail image of a document as base64 JPEG.")(documents.get_document_thumbnail) + mcp.tool(name="read", description="Read both the metadata and text content of a document/message by source ID and document ID.")(documents.read) mcp.tool(name="list_bookmarks", description="List all bookmark names available in the currently open case.")(bookmarks.list_bookmarks) mcp.tool(name="get_bookmark", description="Get all documents in a bookmark by bookmark name.")(bookmarks.get_bookmark) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py index 43e7da944d..ba5dfc5190 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py @@ -7,7 +7,12 @@ def _format_props(props: dict) -> str: lines = [] for k, v in props.items(): - if v is not None and v != "" and v != 0: + if k == "metadata": + if v: + lines.append("\n--- Forensic Metadata ---") + for mk, mv in v.items(): + lines.append(f" {mk}: {mv}") + elif v is not None and v != "" and v != 0: lines.append(f"{k}: {v}") return "\n".join(lines) @@ -60,3 +65,25 @@ async def get_document_thumbnail(source_id: int, doc_id: int) -> Optional[str]: if data is None: return None return base64.b64encode(data).decode("utf-8") + + +async def read(source_id: int, doc_id: int) -> str: + """Read both the metadata and text content of a document by its source ID and document ID. + + Args: + source_id: Source ID (use 0 for single-case) + doc_id: Document ID within the source + """ + try: + props = case_manager.get_item(doc_id, source_id) + props_str = _format_props(props) + except Exception as e: + props_str = f"Error getting metadata: {e}" + + try: + text = case_manager.get_item_text(doc_id, source_id) + text_str = text if text else "[No text content extracted]" + except Exception as e: + text_str = f"Error getting text content: {e}" + + return f"--- METADATA ---\n{props_str}\n\n--- CONTENT ---\n{text_str}" From cb152f06975982fe5ef6c6d87595bce42d64e7ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 9 Jul 2026 16:42:37 -0300 Subject: [PATCH 127/143] refactor: remove get_document_thumbnail tool from MCP and related function --- .../scripts/mcp/iped-mcp-server/src/main.py | 1 - .../mcp/iped-mcp-server/src/tools/documents.py | 12 ------------ 2 files changed, 13 deletions(-) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py index d8f65a5fca..b47efa227b 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py @@ -26,7 +26,6 @@ def register_tools(): mcp.tool(name="get_document", description="Get metadata/properties of a document by source ID and document ID.")(documents.get_document) mcp.tool(name="get_document_content", description="Get the raw binary content of a document as base64.")(documents.get_document_content) mcp.tool(name="get_document_text", description="Get the extracted/parsed text content of a document.")(documents.get_document_text) - mcp.tool(name="get_document_thumbnail", description="Get the thumbnail image of a document as base64 JPEG.")(documents.get_document_thumbnail) mcp.tool(name="read", description="Read both the metadata and text content of a document/message by source ID and document ID.")(documents.read) mcp.tool(name="list_bookmarks", description="List all bookmark names available in the currently open case.")(bookmarks.list_bookmarks) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py index ba5dfc5190..c10f446fcd 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py @@ -54,18 +54,6 @@ async def get_document_text(source_id: int, doc_id: int) -> Optional[str]: return case_manager.get_item_text(doc_id, source_id) -async def get_document_thumbnail(source_id: int, doc_id: int) -> Optional[str]: - """Get the thumbnail image of a document as base64 JPEG. - - Args: - source_id: Source ID (use 0 for single-case) - doc_id: Document ID within the source - """ - data = case_manager.get_item_thumbnail(doc_id, source_id) - if data is None: - return None - return base64.b64encode(data).decode("utf-8") - async def read(source_id: int, doc_id: int) -> str: """Read both the metadata and text content of a document by its source ID and document ID. From 819080f22c6adb64701d92302988c1377df026fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 9 Jul 2026 17:03:44 -0300 Subject: [PATCH 128/143] feat: add read_batch tool to retrieve metadata and text content of multiple documents in a single call --- .../scripts/mcp/iped-mcp-server/src/main.py | 1 + .../iped-mcp-server/src/tools/documents.py | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py index b47efa227b..6ee6795abe 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py @@ -27,6 +27,7 @@ def register_tools(): mcp.tool(name="get_document_content", description="Get the raw binary content of a document as base64.")(documents.get_document_content) mcp.tool(name="get_document_text", description="Get the extracted/parsed text content of a document.")(documents.get_document_text) mcp.tool(name="read", description="Read both the metadata and text content of a document/message by source ID and document ID.")(documents.read) + mcp.tool(name="read_batch", description="Read both the metadata and text content of multiple documents/messages in a single batch call.")(documents.read_batch) mcp.tool(name="list_bookmarks", description="List all bookmark names available in the currently open case.")(bookmarks.list_bookmarks) mcp.tool(name="get_bookmark", description="Get all documents in a bookmark by bookmark name.")(bookmarks.get_bookmark) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py index c10f446fcd..586dd2ba2f 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py @@ -75,3 +75,32 @@ async def read(source_id: int, doc_id: int) -> str: text_str = f"Error getting text content: {e}" return f"--- METADATA ---\n{props_str}\n\n--- CONTENT ---\n{text_str}" + + +async def read_batch(doc_ids: list[int], source_id: int = 0) -> str: + """Read both the metadata and text content of multiple documents by their IDs in a single batch call. + + Args: + doc_ids: List of document IDs within the source + source_id: Source ID (use 0 for single-case) + """ + results = [] + for doc_id in doc_ids: + results.append(f"=== DOCUMENT ID: {doc_id} ===") + try: + props = case_manager.get_item(doc_id, source_id) + props_str = _format_props(props) + results.append(f"--- METADATA ---\n{props_str}") + except Exception as e: + results.append(f"--- METADATA ---\nError getting metadata: {e}") + + try: + text = case_manager.get_item_text(doc_id, source_id) + text_str = text if text else "[No text content extracted]" + results.append(f"--- CONTENT ---\n{text_str}") + except Exception as e: + results.append(f"--- CONTENT ---\nError getting text content: {e}") + + results.append("\n" + "="*40 + "\n") + + return "\n".join(results) From 012cd804600cdba6778a2e0a56994055b50185d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 9 Jul 2026 17:51:52 -0300 Subject: [PATCH 129/143] feat: add get_searchable_fields tool to retrieve metadata field names and types --- .../scripts/mcp/iped-mcp-server/src/case_manager.py | 9 +++++++++ .../resources/scripts/mcp/iped-mcp-server/src/main.py | 1 + .../scripts/mcp/iped-mcp-server/src/tools/search.py | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py index 0faeb43829..0c64a45714 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py @@ -334,5 +334,14 @@ def get_bookmark(self, name: str) -> list[dict]: result.append({"source_id": 0, "id": item_id}) return result + def get_searchable_fields(self) -> dict: + MetadataUtil = get_class("iped.parsers.util.MetadataUtil") + types_map = MetadataUtil.getMetadataTypes() + result = {} + for entry in types_map.entrySet(): + class_name = str(entry.getValue().getName()).split(".")[-1] + result[str(entry.getKey())] = class_name + return result + case_manager = IPEDCaseManager() diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py index 6ee6795abe..5a3c80c1e7 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py @@ -22,6 +22,7 @@ def register_tools(): mcp.tool(name="search", description="Search items using a Lucene query string. Supports IPED's Lucene query syntax.")(search.search) mcp.tool(name="search_by_type", description="Search items by file type extension (e.g., pdf, jpg, docx).")(search.search_by_type) mcp.tool(name="search_by_name", description="Search items by name pattern using wildcards.")(search.search_by_name) + mcp.tool(name="get_searchable_fields", description="Get all searchable metadata field names in the open case and their data types (e.g. String, Date, Integer).")(search.get_searchable_fields) mcp.tool(name="get_document", description="Get metadata/properties of a document by source ID and document ID.")(documents.get_document) mcp.tool(name="get_document_content", description="Get the raw binary content of a document as base64.")(documents.get_document_content) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/search.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/search.py index 5461215b74..9a4635bb04 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/search.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/search.py @@ -38,3 +38,8 @@ async def search_by_name(name_pattern: str, source_id: Optional[int] = None) -> source_id: Optional source ID to restrict search """ return case_manager.search(f"name:{name_pattern}", source_id) + + +async def get_searchable_fields() -> dict: + """Get all searchable metadata field names in the open case and their data types (e.g. String, Date, Integer).""" + return case_manager.get_searchable_fields() From 051fe7ee1714291f38c6d4a3a45ba5bea0125f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Thu, 9 Jul 2026 18:02:49 -0300 Subject: [PATCH 130/143] refactor: add safety limit to read_batch tool for processing multiple documents --- .../resources/scripts/mcp/iped-mcp-server/src/main.py | 2 +- .../scripts/mcp/iped-mcp-server/src/tools/documents.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py index 5a3c80c1e7..be072a39f1 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py @@ -28,7 +28,7 @@ def register_tools(): mcp.tool(name="get_document_content", description="Get the raw binary content of a document as base64.")(documents.get_document_content) mcp.tool(name="get_document_text", description="Get the extracted/parsed text content of a document.")(documents.get_document_text) mcp.tool(name="read", description="Read both the metadata and text content of a document/message by source ID and document ID.")(documents.read) - mcp.tool(name="read_batch", description="Read both the metadata and text content of multiple documents/messages in a single batch call.")(documents.read_batch) + mcp.tool(name="read_batch", description="Read both the metadata and text content of multiple documents/messages in a single batch call. Safety limit: max 50 items.")(documents.read_batch) mcp.tool(name="list_bookmarks", description="List all bookmark names available in the currently open case.")(bookmarks.list_bookmarks) mcp.tool(name="get_bookmark", description="Get all documents in a bookmark by bookmark name.")(bookmarks.get_bookmark) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py index 586dd2ba2f..fb5a38d947 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py @@ -84,7 +84,13 @@ async def read_batch(doc_ids: list[int], source_id: int = 0) -> str: doc_ids: List of document IDs within the source source_id: Source ID (use 0 for single-case) """ - results = [] + MAX_BATCH_SIZE = 50 + warning_msg = "" + if len(doc_ids) > MAX_BATCH_SIZE: + warning_msg = f"[AVISO] O lote de leitura fornecido possui {len(doc_ids)} itens, o que excede o limite máximo de segurança de {MAX_BATCH_SIZE} itens por chamada. Para evitar travamentos e lentidão, apenas os primeiros {MAX_BATCH_SIZE} itens foram lidos. Por favor, refine a sua busca ou leia os itens em grupos menores.\n\n" + doc_ids = doc_ids[:MAX_BATCH_SIZE] + + results = [warning_msg] if warning_msg else [] for doc_id in doc_ids: results.append(f"=== DOCUMENT ID: {doc_id} ===") try: From 2b2651e154e94929b8b752f5764ae581190be012 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 14 Jul 2026 11:15:19 -0300 Subject: [PATCH 131/143] doc: adds sequence diagrams of IPED's chatbot feature --- .../IPED Chatbot sequence diagrams.html | 970 ++++++++++++++++++ 1 file changed, 970 insertions(+) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/documentation/IPED Chatbot sequence diagrams.html diff --git a/iped-app/src/main/java/iped/app/ui/ai/documentation/IPED Chatbot sequence diagrams.html b/iped-app/src/main/java/iped/app/ui/ai/documentation/IPED Chatbot sequence diagrams.html new file mode 100644 index 0000000000..d576d8f7fc --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/documentation/IPED Chatbot sequence diagrams.html @@ -0,0 +1,970 @@ + + + + + + IPED AI Assistant Architecture — Corrected + + + + + +

IPED AI Assistant Architecture

+

+ This document reflects the verified architecture of the AI Assistant module, + reconciled against the actual source code as of July 2026. +

+ + + + + + +
+

The Architecture

+
+
+

View Layer

+

+ AIAssistantPanel: top-level JFrame container (singleton), + layout manager, glass-pane processing lock. +

+

+ HeaderPanel: title, backend status, sidebar toggle. +

+

SidebarPanel: chat history list and selection UI.

+

+ ContextPanel: file context list, per-item removal, + context-edit locking, summarized-mode indicator. +

+

+ ChatAreaPanel: chat rendering, input, streaming + orchestration, citation click handling. +

+

+ AIMarkdownRenderer: markdown-to-StyledDocument parser, + thinking blocks, citation tokens. +

+

+ ChatStreamAnimator: timer-based teletype animation + engine, token queue management. +

+
+ +
+

Controller / Orchestrator Layer

+

+ AIAssistantController: orchestrates event routing, + view updates, chat lifecycle, and persistence. +

+

+ AIChatCoordinator: central pipeline — validates + context entries, routes to the correct backend endpoint based on + chunk count, manages session hashes, streams responses. +

+
+ +
+

Payload / Extraction Layer

+

+ AIPayloadFactory: translates + ContextFileEntry lists into backend DTOs + (AIInitMultiChatRequest, + AIInitMultiChatFullRequest). +

+

+ AIWhatsappChatExtractor: reads item + InputStream into UTF-8 HTML. +

+

+ SummaryValueExtractor: extracts AI summaries from item + extra attributes or Lucene metadata. +

+

+ ContextItemValidator: rejects non-WhatsApp items, empty + files, empty communications. +

+
+ +
+

Network / Backend Layer

+

+ AIBackendClient: HTTP/SSE client implementing + AIBackendService. Six endpoints: /api/init_chat, + /api/chat/stream, + /api/init_multichat_with_summaries, + /api/multichat/stream, + /api/init_multichat_full, + /api/multichat_full/stream. +

+

+ AIBackendConfig: immutable baseUrl + + apiKey. +

+

+ DTOs: AIInitChatRequest, + AIStreamChatRequest, + AIInitMultiChatRequest, + AIInitMultiChatFullRequest, + AIMultiChatStreamRequest. +

+
+ +
+

State Layer

+

+ AIContextManager: singleton, thread-safe context file + list with change-event broadcasting. +

+

+ ConversationManager: singleton, active conversation + state, auto-title generation, conversation list. +

+

+ ConversationPersistence: JSON serialization to/from + <case_dir>/iped/data/ai_chats/. Load, save, + soft-delete. +

+
+
+
+ + + + +
+

Flow 1: App Boot / Load History

+

+ This flow shows how the AI Assistant restores persisted conversations, + initializes controller dependencies, and rebuilds the UI from the current + manager state. +

+
+
+sequenceDiagram +autonumber +participant IPED as IPED Core +participant MainPanel as AIAssistantPanel +participant Controller as AIAssistantController +participant ConvMgr as ConversationManager +participant CtxMgr as AIContextManager +participant Persist as ConversationPersistence +participant Disk as IPED Case Directory +participant SidebarPanel as SidebarPanel +participant ContextPanel as ContextPanel +participant ChatAreaPanel as ChatAreaPanel + +IPED->>MainPanel: getInstance() (lazy singleton) +MainPanel->>MainPanel: new AIAssistantPanel() +MainPanel->>Controller: new AIAssistantController(this) +MainPanel->>MainPanel: createBaseFrame() +MainPanel->>Controller: initialize() +Controller->>Controller: ensureChatServiceInitialized() +Controller->>Controller: new AIChatCoordinator(new AIBackendClient(...)) +Controller->>Controller: initViews() +Controller->>ConvMgr: getInstance() +Controller->>CtxMgr: getInstance() +ConvMgr->>Persist: loadAllConversations() +Persist->>Disk: read chat_*.json / agent_*.json +Disk-->>Persist: return JSON files +Persist-->>ConvMgr: conversations (excluding deleted) +Controller->>Controller: setupStateObservers() +Controller->>MainPanel: assembleLayout(header, sidebar, context, chatArea, tasks) +Controller->>SidebarPanel: updateConversationsList(conversations) +Controller->>ContextPanel: updateContextData(contextEntries) +Controller->>ContextPanel: setSummarizedMode(isSummarized) +Controller->>Controller: refreshChatArea() +Controller->>ChatAreaPanel: renderHistoricalMessages(active messages or empty) +
+
+
+ + + + +
+

Flow 2: Add File to Editable Conversation's Context

+

+ Context additions append files to the current active chat while the + conversation is still directly editable (no assistant reply yet). Files are + validated by ContextItemValidator and appended to the + AIContextManager. +

+
+
+sequenceDiagram +autonumber +actor User +participant IPED as IPED Menu +participant Menu as MenuClass +participant MainPanel as AIAssistantPanel +participant Controller as AIAssistantController +participant ConvMgr as ConversationManager +participant CtxMgr as AIContextManager +participant Validator as ContextItemValidator +participant ContextPanel as ContextPanel + +User->>IPED: right-click file, add to AI context +IPED->>Menu: collect selected IItems +Menu->>MainPanel: addItemsToContext(items) +MainPanel->>Controller: addItemsToContext(items) + +alt isProcessing() is true + Controller->>User: show "wait" dialog +else active conversation is agent type + Controller->>User: show "agent context not supported" dialog +else no active conversation + Controller->>Controller: startNewConversationWithCurrentContext(items) +else active conversation has no assistant reply + Controller->>CtxMgr: addContextFiles(items) + loop for each item + CtxMgr->>Validator: getRejectionReason(item) + alt rejected + CtxMgr->>CtxMgr: add to invalidEntries + else valid + CtxMgr->>CtxMgr: add to contextFiles + end + end + CtxMgr-->>ContextPanel: fireContextChangedEvent() + ContextPanel->>ContextPanel: updateContextData(entries) + Controller->>MainPanel: showFrame() +else active conversation has assistant reply + Controller->>User: confirm auto-fork dialog + alt user selects YES + Controller->>Controller: startNewConversationWithCurrentContext(items) + else user selects NO + Controller->>MainPanel: showFrame() + end +end +
+
+
+ + + + +
+

Flow 3: Auto-Fork

+

+ When the active conversation already has an assistant reply, its context + is no longer directly editable. Adding files at this point starts a new + conversation that carries forward the existing context and merges the newly + selected files. +

+
+
+sequenceDiagram +autonumber +actor User +participant IPED as IPED Menu +participant Menu as MenuClass +participant MainPanel as AIAssistantPanel +participant Controller as AIAssistantController +participant ConvMgr as ConversationManager +participant CtxMgr as AIContextManager +participant Coord as AIChatCoordinator +participant Sidebar as SidebarPanel + +User->>IPED: add file while viewing sealed conversation +IPED->>Menu: collect selected IItems +Menu->>MainPanel: addItemsToContext(newItems) +MainPanel->>Controller: addItemsToContext(newItems) +Controller->>ConvMgr: getActiveConversation() + +alt conversation already has assistant reply + Controller->>User: confirm start new conversation + User-->>Controller: YES + Controller->>Controller: startNewConversationWithCurrentContext(newItems) + Controller->>ConvMgr: startNewConversation() + Note over Controller: Merge existing contextIds + new item IDs + Controller->>Controller: std.setContextIds(mergedIds) + Controller->>Controller: std.setChatHashes(empty list) + Controller->>Controller: newConversation.setMessages(empty list) + Controller->>CtxMgr: addContextFiles(newItems) + Controller->>Coord: clearHistory() + Controller->>Controller: refreshChatArea() + Controller->>Sidebar: updateConversationsList(conversations) + Controller->>Sidebar: setSelectedValue(newConversation) + Controller->>MainPanel: showFrame() +else user cancels + Controller->>MainPanel: showFrame() +end +
+
+
+ + + + +
+

Flow 4: Send Question / Streaming

+

+ Message sending delegates UI events to + AIAssistantController, which routes through + AIChatCoordinator. The coordinator validates context entries, + computes the total chunk count, and selects one of three backend paths: +

+
    +
  • Single Chat — 1 context entry: extracts HTML, calls initChat + streamChatResponse
  • +
  • Multi-Chat Full — 2+ entries, totalChunks ≤ 10: builds raw-HTML payload via AIPayloadFactory.buildMultiChatFullRequest, calls initMultiChatFull + streamMultiChatFullResponse
  • +
  • Multi-Chat Summarized — 2+ entries, totalChunks > 10: builds summary payload via AIPayloadFactory.buildMultiChatRequest, calls initMultiChat + streamMultiChatResponse
  • +
+

+ Tokens stream through AIBackendClient (SSE) → + ChatStreamAnimator (timer queue) → + AIMarkdownRenderer (StyledDocument). The draft is committed + only after the stream drains. +

+
+
+sequenceDiagram +autonumber +actor User +participant ChatArea as ChatAreaPanel +participant Controller as AIAssistantController +participant ConvMgr as ConversationManager +participant Coord as AIChatCoordinator +participant CtxMgr as AIContextManager +participant Extractor as AIWhatsappChatExtractor +participant PayloadFactory as AIPayloadFactory +participant Backend as AIBackendClient +participant Animator as ChatStreamAnimator +participant Renderer as AIMarkdownRenderer +participant Persist as ConversationPersistence +participant Sidebar as SidebarPanel +participant MainPanel as AIAssistantPanel + +User->>ChatArea: type prompt and press Send +ChatArea->>Controller: sendButton / keyPressed(ENTER) +Controller->>Controller: handleSendAction() + +alt text is empty + Controller-->>User: return (no-op) +end + +Controller->>Controller: ensureChatServiceInitialized() + +alt no active conversation + Controller->>ConvMgr: startNewConversation() + Controller->>Sidebar: updateConversationsList(conversations) +end + +Controller->>ConvMgr: addMessageToActive(userMessage) +ConvMgr->>ConvMgr: add message, updateLastModified() +opt first user message and default title + ConvMgr->>ConvMgr: autoGenerateTitle(conversation) +end +ConvMgr-)Persist: saveConversation() [async thread] + +Controller->>ChatArea: setText("") (clear input) +Controller->>MainPanel: setProcessing(true) + +Controller->>ChatArea: startMessageStreaming(assistantDraft) +ChatArea->>Animator: beginStreaming(assistantDraft) +ChatArea->>Renderer: renderDraft(assistantDraft) + +Controller->>Coord: askQuestion(text, uiCallback, onComplete, onError) + +Note over Coord, Backend: Background thread starts here + +Coord->>CtxMgr: getContextEntriesForUI() +Coord->>Coord: filter validEntries (isValidForContext) +Coord->>Coord: calculateTotalChunks(validEntries) + +alt validEntries is empty + Coord-->>Controller: onError("Please add at least one valid file") +end + +Coord->>Coord: compute newContextIds +Coord->>Coord: contextChanged = !newContextIds.equals(currentContextItemIds) +Coord->>Coord: needsInitialization = contextChanged || currentChatHashes.isEmpty() + +opt needsInitialization + Coord->>Coord: chatHistory.clear(), currentChatHashes.clear() + + alt validEntries.size() == 1 (SINGLE CHAT) + Coord->>Extractor: extractHtml(item) + Extractor-->>Coord: html string + Coord->>Backend: initChat(html) + Backend->>Backend: POST /api/init_chat + Backend-->>Coord: chatHash (String) + Coord->>Coord: currentChatHashes.add(hash) + + else totalChunks <= 10 (MULTI-CHAT FULL) + Coord->>PayloadFactory: buildMultiChatFullRequest(validEntries) + loop for each valid entry + PayloadFactory->>Extractor: extractHtml(item) + Extractor-->>PayloadFactory: rawHtml + end + PayloadFactory-->>Coord: AIInitMultiChatFullRequest + Coord->>Backend: initMultiChatFull(request) + Backend->>Backend: POST /api/init_multichat_full + Backend-->>Coord: List of chatHashes + Coord->>Coord: currentChatHashes.addAll(hashes) + + else totalChunks > 10 (MULTI-CHAT SUMMARIZED) + Coord->>PayloadFactory: buildMultiChatRequest(validEntries) + loop for each valid entry with summary + PayloadFactory->>PayloadFactory: extractList(SUMMARY) + PayloadFactory->>PayloadFactory: extractList(CHUNK_IDS) + end + PayloadFactory-->>Coord: AIInitMultiChatRequest + Coord->>Backend: initMultiChat(request) + Backend->>Backend: POST /api/init_multichat_with_summaries + Backend-->>Coord: List of chatHashes + Coord->>Coord: currentChatHashes.addAll(hashes) + end + + Coord->>Coord: currentContextItemIds = newContextIds + Coord->>ConvMgr: getActiveConversation() + Coord->>Coord: std.setContextIds(currentContextItemIds) + Coord->>Coord: std.setChatHashes(currentChatHashes) + Coord->>Coord: std.updateLastModified() + Coord-)Persist: saveConversation(activeConversation) [async] +end + +Note over Coord, Backend: Stream response via correct endpoint + +alt currentChatHashes.size() == 1 (SINGLE CHAT) + Coord->>Backend: streamChatResponse(hash, question, chatHistory, callback) + Backend->>Backend: POST /api/chat/stream (SSE) +else totalChunks <= 10 (MULTI-CHAT FULL) + Coord->>Backend: streamMultiChatFullResponse(hashes, question, chatHistory, callback) + Backend->>Backend: POST /api/multichat_full/stream (SSE) +else totalChunks > 10 (MULTI-CHAT SUMMARIZED) + Coord->>Backend: streamMultiChatResponse(hashes, question, chatHistory, callback) + Backend->>Backend: POST /api/multichat/stream (SSE) +end + +loop each SSE token + Backend->>Backend: processSseStream() parse data: lines + Backend-->>Coord: token via eventHandler callback + Coord->>Coord: fullResponse.append(token) + Coord-->>Controller: uiCallback(token) on EDT + Controller->>ChatArea: enqueueStreamingToken(token) + ChatArea->>Animator: enqueueToken(token) + Animator->>Animator: onTimerTick() dequeue + appendContent() + Animator->>Renderer: renderDraft(updated assistantDraft) +end + +Coord->>Coord: chatHistory.add(user turn) +Coord->>Coord: chatHistory.add(assistant turn) + +Coord-->>Controller: onComplete() [via finally block] +Controller->>ChatArea: pruneStreaming(onDrained) +ChatArea->>Animator: completeStreaming(onDrained) + +alt assistantDraft has content + ChatArea->>Renderer: commitDraft() + Controller->>ConvMgr: addMessageToActive(assistantDraft) + ConvMgr->>ConvMgr: add message, updateLastModified() + ConvMgr-)Persist: saveConversation() [async thread] + Controller->>Sidebar: updateConversationsList(conversations) +else draft empty + ChatArea->>Renderer: discardDraft() +end + +Controller->>MainPanel: setProcessing(false) +
+
+
+ + + + +
+

Flow 5: Backend Recovery & Partial Draft Salvage

+

+ The coordinator preserves successful initialization state during + streaming failures. If streaming fails mid-response, the UI salvages + whatever partial text the LLM managed to generate, commits it to memory, + and appends a system error message so the user doesn't lose their data. +

+
+
+sequenceDiagram +autonumber +participant Controller as AIAssistantController +participant Coord as AIChatCoordinator +participant Backend as AIBackendClient +participant ChatArea as ChatAreaPanel +participant Renderer as AIMarkdownRenderer +participant ConvMgr as ConversationManager +participant Sidebar as SidebarPanel +participant MainPanel as AIAssistantPanel + +Controller->>Coord: askQuestion(...) + +Coord->>Backend: initialize (initChat / initMultiChat / initMultiChatFull) + +alt initialization fails + Backend--xCoord: AIBackendException + Coord->>Coord: currentChatHashes.clear() + Coord->>Coord: currentContextItemIds.clear() + Coord-->>Controller: onError(errorMessage) + + rect rgb(255, 240, 245) + Note over Controller, Renderer: Salvage draft if any UI draft exists + Controller->>ChatArea: salvageStreamingDraft() + Note right of ChatArea: Internally calls commitDraft() or discardDraft() + ChatArea-->>Controller: partialDraft (or null) + alt partialDraft != null + Controller->>ConvMgr: addMessageToActive(partialDraft) + Controller->>Sidebar: updateConversationsList(conversations) + end + end + + Controller->>ConvMgr: addMessageToActive(systemErrorMessage) + Controller->>MainPanel: setProcessing(false) + +else initialization succeeded + Backend-->>Coord: init ok (hashes stored) + + Coord->>Backend: stream response (SSE) + + alt streaming fails after init + Backend--xCoord: connection drop / HTTP 500 + Note over Coord: Preserve currentChatHashes and currentContextItemIds + Coord-->>Controller: onError(errorMessage) + + rect rgb(255, 240, 245) + Note over Controller, Renderer: Salvage partial draft + Controller->>ChatArea: salvageStreamingDraft() + Note right of ChatArea: Internally calls commitDraft() or discardDraft() + ChatArea-->>Controller: partialDraft (or null) + alt partialDraft != null + Controller->>ConvMgr: addMessageToActive(partialDraft) + Controller->>Sidebar: updateConversationsList(conversations) + end + end + + Controller->>ConvMgr: addMessageToActive(systemErrorMessage) + Controller->>MainPanel: setProcessing(false) + + else streaming completes successfully + Backend-->>Coord: complete response + Coord-->>Controller: onComplete() + end +end +
+
+
+ + + + +
+

Flow 6: Auto-Generate Title

+

+ Title generation is centralized inside ConversationManager. + When the first user message is added to a conversation whose title is still + the default "New Conversation", a substring of the message is + used as the title. +

+
+
+sequenceDiagram +autonumber +participant Controller as AIAssistantController +participant ConvMgr as ConversationManager +participant Conversation as Conversation +participant Persist as ConversationPersistence +participant SidebarPanel as SidebarPanel + +Controller->>ConvMgr: addMessageToActive(userMessage) +ConvMgr->>Conversation: add message +ConvMgr->>Conversation: updateLastModified() +opt title is "New Conversation" and message type is user + ConvMgr->>ConvMgr: autoGenerateTitle(conversation) + ConvMgr->>Conversation: setTitle(substring of prompt, max 30 chars) +end +ConvMgr-)Persist: saveConversation(conversation) [async thread] +Controller->>SidebarPanel: updateConversationsList(conversations) +
+
+
+ + + + +
+

Flow 7: Quick Actions

+

+ Quick Action buttons inject a prebuilt prompt into the chat input and + reuse the existing send flow. +

+
+
+sequenceDiagram +autonumber +actor User +participant Tasks as Quick Actions Panel +participant ChatArea as ChatAreaPanel +participant Controller as AIAssistantController + +User->>Tasks: click "Summarize" or "Find Patterns" +Tasks->>ChatArea: setText(predefined prompt) +Tasks->>Controller: handleSendAction() +Note right of Controller: reuses Flow 4 (send question + stream) +
+
+
+ + + + +
+

Flow 8: Switch Conversation

+

+ Conversation switching restores visible chat state immediately, clears any + active draft and current context, loads historical coordinator state, then + performs a background recovery of persisted context items before replaying + coordinator state with refreshed IDs. +

+
+
+sequenceDiagram +autonumber +actor User +participant Sidebar as SidebarPanel +participant Controller as AIAssistantController +participant ConvMgr as ConversationManager +participant Coord as AIChatCoordinator +participant CtxMgr as AIContextManager +participant ChatArea as ChatAreaPanel +participant IPED as IPED Searcher +participant AppCase as IPED Case + +User->>Sidebar: select conversation +Sidebar->>Controller: onConversationSelected(conv) +Controller->>Controller: isSwitchingChats = true +Controller->>ConvMgr: setActiveConversation(conv) +alt coordinator exists and StandardConversation + Controller->>ConvMgr: getActiveConversation() + Controller->>Coord: loadHistoricalContext(conv.chatHashes, conv.contextIds, conv.messages) +else coordinator exists and AgentConversation + Controller->>Coord: loadHistoricalContext(null, null, conv.messages) +end + +alt isAgentConversation + Controller->>Controller: isSwitchingChats = false + Controller->>ChatArea: forceDiscardStreaming() + Controller->>Controller: refreshChatArea() + Controller->>Sidebar: setSelectedValue(conv) + Note over Controller: early return +end + +Controller->>CtxMgr: clearContext() + +par background restore of context items + Controller->>IPED: search by hash list or context id list + IPED->>AppCase: resolve IItem objects + AppCase-->>Controller: restoredItems +and immediate UI switch + Controller->>ChatArea: forceDiscardStreaming() + Controller->>Controller: refreshChatArea() + Controller->>Sidebar: setSelectedValue(conv) +end + +Controller->>Controller: SwingUtilities.invokeLater(...) +alt restoredItems not empty and conversation still active + Controller->>Coord: loadHistoricalContext(conv.chatHashes, freshIds, conv.messages) + Controller->>CtxMgr: addContextFiles(restoredItems) +end +Controller->>Controller: isSwitchingChats = false +
+
+
+ + + + +
+

Flow 9: Click Citation

+

+ A citation click is handled in ChatAreaPanel and delegated + to the controller for forensic item navigation. +

+
+
+sequenceDiagram +autonumber +actor User +participant ChatArea as ChatAreaPanel +participant Renderer as AIMarkdownRenderer +participant Controller as AIAssistantController +participant IPED as IPED Searcher +participant Viewer as IPED Viewer +participant App as IPED App + +User->>ChatArea: click citation token +ChatArea->>ChatArea: read TOKEN_ATTRIBUTE from character element +ChatArea->>ChatArea: extract TOKEN_HASH_ATTRIBUTE + TOKEN_CHUNK_ID_ATTRIBUTE +ChatArea->>Controller: navigationCallback(hash, chunkId) +Controller->>IPED: search by hash ("hash:" + hash) +IPED-->>Controller: itemId / luceneId +opt chunkId is not empty + Controller->>Viewer: setElementIDToScroll(chunkId) +end +Controller->>App: new FileProcessor(luceneId, true).execute() +Controller->>App: selectItemInResultsTable(luceneId) +
+
+
+ + + + +
+

Flow 10: Start New Chat

+

+ New chat creation is handled by the controller before clearing UI and + context state. After the reset, the controller appends a system + message announcing that a new conversation session has started. +

+
+
+sequenceDiagram +autonumber +actor User +participant Sidebar as SidebarPanel +participant Controller as AIAssistantController +participant ConvMgr as ConversationManager +participant Coord as AIChatCoordinator +participant CtxMgr as AIContextManager +participant ChatArea as ChatAreaPanel +participant MainPanel as AIAssistantPanel + +User->>Sidebar: click "+ New Chat" +Sidebar->>Controller: onNewChatRequested() +Controller->>ConvMgr: startNewConversation() +Controller->>MainPanel: setContextPanelVisible(true) +Controller->>Controller: clearChatScreenAndMemory() +Controller->>Coord: clearHistory() +Controller->>ChatArea: clearChatScreen() +Controller->>CtxMgr: clearContext() +Controller->>Sidebar: updateConversationsList(conversations) +Controller->>Controller: refreshChatArea() +Controller->>ConvMgr: addMessageToActive(systemMessage) +Controller->>Controller: refreshChatArea() +Controller->>ChatArea: requestFocusToInput() +
+
+
+ + + + +
+

Flow 11: Persistence

+

+ Conversations are saved to disk on every meaningful change. Persistence + is asynchronous (background threads). Deleted chats are soft-deleted. +

+
+
+sequenceDiagram +autonumber +participant Controller as AIAssistantController +participant ConvMgr as ConversationManager +participant CtxMgr as AIContextManager +participant Coord as AIChatCoordinator +participant Conversation as Conversation +participant Persist as ConversationPersistence +participant Disk as Case Directory + +alt user / assistant message added + Controller->>ConvMgr: addMessageToActive(msg) + ConvMgr->>Conversation: add message + ConvMgr->>Conversation: updateLastModified() + opt first user message and default title + ConvMgr->>ConvMgr: autoGenerateTitle(conversation) + ConvMgr->>Conversation: setTitle(substring of prompt) + end + ConvMgr-)Persist: saveConversation(conversation) [async thread] + +else context changed + CtxMgr-->>Controller: contextChanged(event) [via listener] + alt not switching chats (isSwitchingChats == false) + Controller->>Conversation: setContextIds(currentIds) + Controller->>Conversation: updateLastModified() + Controller-)Persist: saveConversation(activeConversation) [async thread] + else switching chats + Controller->>Controller: skip persistence + end + +else initialization in coordinator + Coord->>Conversation: setContextIds + setChatHashes + Coord->>Conversation: updateLastModified() + Coord-)Persist: saveConversation(activeConversation) [async thread] +end + +Persist->>Disk: write chat_[uuid].json or agent_[uuid].json +Note right of Persist: deleted conversations are preserved with status = deleted +
+
+
+ + + + +
+

Flow 12: Delete Conversation

+

+ Deletion marks the conversation as deleted (soft-delete) and keeps the + JSON file for auditability. If the deleted conversation is the active one, + the controller loads the next remaining conversation or clears the screen + if none remain. +

+
+
+sequenceDiagram +autonumber +actor User +participant Sidebar as SidebarPanel +participant Controller as AIAssistantController +participant Persist as ConversationPersistence +participant ConvMgr as ConversationManager +participant ChatArea as ChatAreaPanel +participant CtxMgr as AIContextManager + +User->>Sidebar: click X on chat row +Sidebar->>Controller: onDeleteRequested(conv) +Controller->>Persist: deleteConversation(conv) +Persist->>Persist: conv.setStatus("deleted") +Persist->>Persist: saveConversation(conv) +Controller->>ConvMgr: removeConversation(conv) + +alt deleted active chat + Controller->>ConvMgr: getConversations() + alt remaining conversations exist + Controller->>ConvMgr: setActiveConversation(remaining.get(0)) + Controller->>Controller: loadConversation(remaining.get(0)) + else no conversations remain + Controller->>ConvMgr: setActiveConversation(null) + Controller->>ChatArea: clearChatScreen() + Controller->>CtxMgr: clearContext() + Controller->>Controller: refreshChatArea() + end +end + +Controller->>Sidebar: updateConversationsList(conversations) +
+
+
+ + + From 4d54f9d302790e3afae2134fce293e3ab0252db2 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 14 Jul 2026 11:56:22 -0300 Subject: [PATCH 132/143] doc: adds sequence diagrams of IPED's agent feature --- .../agent/Agent sequence diagrams.html | 815 ++++++++++++++++++ .../Chatbot sequence diagrams.html} | 0 2 files changed, 815 insertions(+) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html rename iped-app/src/main/java/iped/app/ui/ai/documentation/{IPED Chatbot sequence diagrams.html => chatbot/Chatbot sequence diagrams.html} (100%) diff --git a/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html b/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html new file mode 100644 index 0000000000..b373f7dd0e --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html @@ -0,0 +1,815 @@ + + + + + + IPED Agent Feature Architecture + + + + + +

IPED Agent Feature Architecture

+

+ This document describes the agent feature in IPED, where an LLM-powered + agent (via opencode CLI) interacts with the IPED case through + an MCP (Model Context Protocol) server. The agent can search, read, and + reference forensic items using natural language. +

+ + + + + + +
+

Architecture Overview

+
+
+

View Layer

+

+ AIAssistantPanel: top-level JFrame container. Controls + context-panel visibility (hidden for agent conversations). +

+

+ SidebarPanel: provides "New Agent Chat" dropdown + option alongside "New Chat". +

+

+ ChatAreaPanel: renders agent responses with + streaming, handles citation token clicks. +

+

+ AIMarkdownRenderer: parses agent output markdown + including <<hash-chunkId>> citation tokens. +

+

+ ⚠ Citation rendering is in early stages. The token format is defined + but end-to-end navigation (MCP → renderer → item viewer) has known + issues. See Flow 6. +

+
+ +
+

Controller / Orchestrator Layer

+

+ AIAssistantController: routes send actions to + AIChatCoordinator.askAgentQuestion() when the active + conversation is an AgentConversation. +

+

+ AIChatCoordinator: extracts the session ID from the + active AgentConversation, creates the + onSessionIdFound callback, and delegates to + OpenCodeAgentService. +

+
+ +
+

Agent Layer (JVM Process)

+

+ OpenCodeAgentService: spawns opencode + as a subprocess. Manages command construction, session ID extraction + from JSON output, real-time JSON-Lines parsing, and local chat + history. +

+

+ AgentConversation: extends Conversation, + adds sessionId field for multi-turn opencode session + persistence. +

+
+ +
+

External Process: opencode CLI

+

+ opencode: standalone CLI agent. Reads + opencode.json config. Connects to a local LLM provider + (e.g. Qwen3.5-122B). Manages its own tool-calling loop. Outputs + JSON-Lines to stdout. +

+

+ opencode.json: defines MCP server command + (python -m src.main), model provider, context limits. +

+
+ +
+

MCP Server (Python, stdio transport)

+

+ FastMCP Server: registered as iped tool + provider in opencode. Runs inside the opencode process via stdio. +

+

+ IPEDCaseManager: wraps Java IPED classes via PyJnius. + Opens the case, performs Lucene searches, reads item metadata/text. +

+

+ JVM Bridge: initializes JVM with IPED classpath via + jnius_config. Provides get_class() and + cast_to() helpers. +

+

+ MCP Tools: search, + search_by_type, search_by_name, + get_searchable_fields, get_document, + get_document_content, get_document_text, + read, read_batch, + list_bookmarks, get_bookmark, + list_sources, get_citation_token, + get_citation_info, get_citation_tokens_batch. +

+

+ ⚠ Citation tools (get_citation_token, + get_citation_info, + get_citation_tokens_batch) are implemented but the + hash semantics are not yet aligned with the Java-side navigation + handler. See Flow 6. +

+
+ +
+

State Layer

+

+ ConversationManager: manages active conversation. + Agent conversations use startNewConversation(true). +

+

+ ConversationPersistence: saves agent conversations + with agent_ prefix. Persists sessionId for + session resumption. +

+

+ AgentConversation.sessionId: opencode session ID + extracted from JSON output via regex. Enables multi-turn context. +

+
+
+
+ + + + +
+

Flow 1: Start Agent Chat

+

+ The user selects "New Agent Chat" from the sidebar dropdown. The + controller creates an AgentConversation, hides the context + panel (agent mode doesn't use file context), clears previous state, and + appends a system message. +

+
+
+sequenceDiagram +autonumber +actor User +participant Sidebar as SidebarPanel +participant Controller as AIAssistantController +participant ConvMgr as ConversationManager +participant Coord as AIChatCoordinator +participant CtxMgr as AIContextManager +participant ChatArea as ChatAreaPanel +participant MainPanel as AIAssistantPanel + +User->>Sidebar: click dropdown, select "New Agent Chat" +Sidebar->>Controller: onNewAgentChatRequested() +Controller->>ConvMgr: startNewConversation(true) +Note over ConvMgr: Creates AgentConversation (isAgentConversation=true) +Controller->>MainPanel: setContextPanelVisible(false) +Controller->>Controller: clearChatScreenAndMemory() +Controller->>CtxMgr: clearContext() +Controller->>Sidebar: updateConversationsList(conversations) +Controller->>Controller: refreshChatArea() +Controller->>ConvMgr: addMessageToActive(systemMessage) +Note over ConvMgr: "Started a new agent conversation session." +Controller->>Controller: refreshChatArea() +Controller->>ChatArea: requestFocusToInput() +
+
+
+ + + + +
+

Flow 2: First Agent Question (No Session)

+

+ On the first question in a new agent conversation, no session ID exists. + OpenCodeAgentService spawns opencode run as a + subprocess. The opencode process starts the MCP server, connects to the + LLM, and begins a tool-calling loop. The session ID is extracted from the + JSON-Lines output and persisted for future turns. +

+
+
+sequenceDiagram +autonumber +actor User +participant ChatArea as ChatAreaPanel +participant Controller as AIAssistantController +participant Coord as AIChatCoordinator +participant AgentSvc as OpenCodeAgentService +participant ConvMgr as ConversationManager +participant Persist as ConversationPersistence +participant MainPanel as AIAssistantPanel + +User->>ChatArea: type question and press Send +ChatArea->>Controller: handleSendAction() +Controller->>ConvMgr: getActiveConversation() +Note over Controller: activeConv.isAgentConversation() == true +Controller->>ConvMgr: addMessageToActive(userMessage) +Controller->>ChatArea: setText("") (clear input) +Controller->>MainPanel: setProcessing(true) +Controller->>ChatArea: startMessageStreaming(assistantDraft) + +Controller->>Coord: askAgentQuestion(text, uiCallback, onComplete, onError) +Coord->>ConvMgr: getActiveConversation() +Coord->>Coord: sessionId = null (first turn) +Coord->>AgentSvc: askAgentQuestion(text, uiCallback, onSessionIdFound, null, onComplete, onError) + +Note over AgentSvc: Background thread starts + +AgentSvc-->>Controller: uiCallback("**[Agent]:** Executando opencode...") + +AgentSvc->>AgentSvc: findMcpServerDir() +Note over AgentSvc: Resolves: appRoot/scripts/mcp/iped-mcp-server +AgentSvc->>AgentSvc: resolveOpencodeCommand() +Note over AgentSvc: Resolves: APPDATA/npm/opencode.cmd or "opencode" + +AgentSvc->>AgentSvc: Build command +Note over AgentSvc: opencode run <question> --auto --format json + +AgentSvc->>AgentSvc: ProcessBuilder.directory(mcpServerDir) +AgentSvc->>AgentSvc: environment.put(OPENCODE_CONFIG, opencode.json) +AgentSvc->>AgentSvc: process.start() +AgentSvc->>AgentSvc: process.getOutputStream().close() + +loop read stdout (char[] chunks) + AgentSvc->>AgentSvc: buffer.append(chunk) + + opt !sessionIdExtracted + AgentSvc->>AgentSvc: regex match "sessionID":"ses_xxx" + AgentSvc-->>Coord: onSessionIdFound(sessionId) + Coord->>ConvMgr: getActiveConversation() + Coord->>ConvMgr: agentConv.setSessionId(sessionId) + Coord-)Persist: saveConversation(agentConv) [async] + end + + AgentSvc->>AgentSvc: extractTextFromJsonLines(completeLines) + Note over AgentSvc: Filters: text -> display, tool_use -> hide, step_start -> hide + AgentSvc-->>Controller: uiCallback(textContent) + Controller->>ChatArea: enqueueStreamingToken(token) +end + +AgentSvc->>AgentSvc: process.waitFor() +alt exitCode == 0 (success) + AgentSvc->>AgentSvc: chatHistory.add(user turn) + AgentSvc->>AgentSvc: chatHistory.add(assistant turn) +else exitCode != 0 (failure) + AgentSvc-->>Controller: onError("process terminated with error code") +end + +AgentSvc-->>Controller: onComplete() +Controller->>ChatArea: pruneStreaming(onDrained) +ChatArea->>ChatArea: commitDraft() or discardDraft() +Controller->>ConvMgr: addMessageToActive(assistantDraft) +Controller->>MainPanel: setProcessing(false) +
+
+
+ + + + +
+

Flow 3: Follow-Up Agent Question (With Session)

+

+ On subsequent questions, the sessionId is passed to + opencode --session <id>. This allows opencode to + resume the previous conversation context, including prior tool calls and + LLM responses. The MCP server does not re-initialize since it runs inside + the same opencode process. +

+
+
+sequenceDiagram +autonumber +actor User +participant ChatArea as ChatAreaPanel +participant Controller as AIAssistantController +participant Coord as AIChatCoordinator +participant AgentSvc as OpenCodeAgentService +participant ConvMgr as ConversationManager +participant Persist as ConversationPersistence +participant MainPanel as AIAssistantPanel + +User->>ChatArea: type follow-up question and press Send +ChatArea->>Controller: handleSendAction() +Controller->>ConvMgr: addMessageToActive(userMessage) +Controller->>ChatArea: setText("") (clear input) +Controller->>MainPanel: setProcessing(true) +Controller->>ChatArea: startMessageStreaming(assistantDraft) + +Controller->>Coord: askAgentQuestion(text, uiCallback, onComplete, onError) +Coord->>ConvMgr: getActiveConversation() +Coord->>Coord: sessionId = agentConv.getSessionId() +Note over Coord: sessionId is non-null (e.g. "ses_abc123") +Coord->>AgentSvc: askAgentQuestion(text, uiCallback, onSessionIdFound, sessionId, onComplete, onError) + +AgentSvc->>AgentSvc: Build command +Note over AgentSvc: opencode run <question> --auto --format json --session ses_abc123 + +AgentSvc->>AgentSvc: process.start() + +loop read stdout + AgentSvc->>AgentSvc: extractTextFromJsonLines() + AgentSvc-->>Controller: uiCallback(textContent) + Controller->>ChatArea: enqueueStreamingToken(token) +end + +AgentSvc->>AgentSvc: process.waitFor() (exitCode == 0) +AgentSvc->>AgentSvc: chatHistory.add(user + assistant turns) +AgentSvc-->>Controller: onComplete() +Controller->>ChatArea: pruneStreaming(onDrained) +Controller->>ConvMgr: addMessageToActive(assistantDraft) +Controller->>MainPanel: setProcessing(false) +
+
+
+ + + + +
+

Flow 4: MCP Server Initialization

+

+ When opencode starts, it reads opencode.json + and launches the MCP server as a subprocess (python -m src.main). + The MCP server validates configuration, initializes the JVM with IPED + classpath, opens the forensic case, and registers all tools. +

+
+
+sequenceDiagram +autonumber +participant OC as opencode CLI +participant Config as opencode.json +participant MCP_Main as MCP Server (main.py) +participant Settings as Settings (config.py) +participant JVM as JVM Bridge (jvm_bridge.py) +participant CaseMgr as IPEDCaseManager +participant Tools as MCP Tools + +OC->>Config: read opencode.json +Config-->>OC: mcp.iped.command = ["python", "-m", "src.main"] +Config-->>OC: model = "local/Qwen3.5-122B" + +OC->>MCP_Main: spawn subprocess (stdio transport) +MCP_Main->>Settings: settings.validate() +Settings->>Settings: check IPED_HOME, CASE_PATH, JAVA_HOME, JVM_MAX_HEAP +Settings-->>MCP_Main: errors (if any) or OK + +alt validation failed + MCP_Main->>MCP_Main: logger.error() + sys.exit(1) +end + +MCP_Main->>JVM: ensure_jvm() +JVM->>Settings: settings.classpath_jars +Settings-->>JVM: iped-api.jar, iped-engine.jar, iped-utils.jar + lib/*.jar +JVM->>JVM: jnius_config.add_options("-Xmx4g", "-Djava.awt.headless=true") +JVM->>JVM: jnius_config.add_classpath(jars...) +JVM-->>MCP_Main: JVM ready + +MCP_Main->>CaseMgr: open_case() +CaseMgr->>CaseMgr: get_class("iped.engine.data.IPEDSource") +CaseMgr->>CaseMgr: IPEDSource.checkIfIsCaseFolder(caseFile) +alt single case + CaseMgr->>CaseMgr: IPEDSource(caseFile) + CaseMgr-->>MCP_Main: {type: "single", total_items: N} +else multi-source + CaseMgr->>CaseMgr: IPEDMultiSource(caseFile) + CaseMgr-->>MCP_Main: {type: "multi", source_count: N, total_items: M} +end + +MCP_Main->>Tools: register_tools() +Note over Tools: 15 tools registered: search, search_by_type, search_by_name, +Note over Tools: get_searchable_fields, get_document, get_document_content, +Note over Tools: get_document_text, read, read_batch, list_bookmarks, +Note over Tools: get_bookmark, list_sources, get_citation_token, +Note over Tools: get_citation_info, get_citation_tokens_batch + +MCP_Main->>MCP_Main: mcp.run(transport="stdio") +Note over MCP_Main: Ready to receive tool calls from opencode +
+
+
+ + + + +
+

Flow 5: MCP Tool Execution

+

+ When the LLM decides to call a tool, opencode sends the tool invocation + to the MCP server over stdio. The MCP server executes the tool (which + may involve JVM calls via PyJnius), and returns the result. The LLM + receives the result and continues generating. +

+
+
+sequenceDiagram +autonumber +participant LLM as LLM (Qwen3.5-122B) +participant OC as opencode CLI +participant AgentSvc as OpenCodeAgentService +participant MCP as MCP Server +participant CaseMgr as IPEDCaseManager +participant JVM as JVM Bridge (PyJnius) +participant IPED as IPED Java Classes + +LLM->>OC: tool_use: search(query="name:*.jpg") +OC->>MCP: JSON-RPC: tools/call {"name":"search","arguments":{"query":"name:*.jpg"}} +MCP->>CaseMgr: case_manager.search("name:*.jpg") +CaseMgr->>JVM: get_class("iped.engine.search.IPEDSearcher") +JVM-->>CaseMgr: IPEDSearcher class +CaseMgr->>CaseMgr: IPEDSearcher(source, "name:*.jpg") +CaseMgr->>CaseMgr: searcher.setNoScoring(true) +CaseMgr->>IPED: searcher.search() +IPED-->>CaseMgr: java_result +CaseMgr->>CaseMgr: extract IDs from result +CaseMgr-->>MCP: {total: N, ids: [...]} + +MCP->>CaseMgr: case_manager.get_item(doc_id) [enrichment loop] +CaseMgr->>IPED: source.getItemByID(id) +IPED-->>CaseMgr: item properties +CaseMgr-->>MCP: enriched results with hashes + +MCP-->>OC: JSON-RPC result: {total, enriched_ids: [{id, hash, name}]} +OC->>LLM: tool_result: search results with hashes + +Note over LLM: LLM processes results, may call more tools +LLM->>OC: tool_use: read(source_id=0, doc_id=42) +OC->>MCP: JSON-RPC: tools/call {"name":"read","arguments":{...}} +MCP->>CaseMgr: case_manager.get_item(42, 0) +CaseMgr->>IPED: source.getItemByID(42) +IPED-->>CaseMgr: metadata +MCP->>CaseMgr: case_manager.get_item_text(42, 0) +CaseMgr->>IPED: item.getTextReader() +IPED-->>CaseMgr: text content +MCP-->>OC: {hash_info, metadata, content} +OC->>LLM: tool_result: document metadata + text + +LLM->>OC: text response with citation tokens +OC-->>AgentSvc: JSON-Lines stdout with text content +
+
+
+ + + + +
+

Flow 6: Agent Citation Generation

+
+ ⚠ Work in Progress
+ Citation features are in their early stages. The MCP tools, renderer + token parsing, and click handler are all implemented, but the + end-to-end flow is not yet fully working. The hash value used in + citation tokens (Lucene internal ID) does not yet match what the + Java navigation handler expects (content hash). This diagram + documents the intended architecture at this snapshot in time. +
+

+ The agent generates citations using the <<hash-chunkId>> + format. This can happen in two ways: (1) the LLM constructs citations + directly from tool result hashes, or (2) the LLM calls the dedicated + get_citation_token / get_citation_info MCP + tools. The resulting tokens are rendered as clickable links by + AIMarkdownRenderer in the chat UI. +

+

+ Note: the Mermaid diagram below uses [[...]] to represent + <<...>> tokens, which conflict with Mermaid's + << stereotype syntax. +

+
+
+sequenceDiagram +autonumber +participant LLM as LLM (Qwen3.5-122B) +participant OC as opencode CLI +participant MCP as MCP Server +participant CaseMgr as IPEDCaseManager +participant AgentSvc as OpenCodeAgentService +participant ChatArea as ChatAreaPanel +participant Renderer as AIMarkdownRenderer +participant Controller as AIAssistantController +participant IPED as IPED Searcher / Viewer +actor User + +Note over LLM: LLM decides to reference a document + +alt LLM constructs citation from tool result hash + LLM->>OC: 'text: "Found document [[12345-ch42]] in the case"' + OC-->>AgentSvc: 'text: "Found document [[12345-ch42]]..."' +else LLM calls citation tool + LLM->>OC: 'tool_use: get_citation_token(source_id=0, doc_id=42)' + OC->>MCP: 'JSON-RPC: tools/call' + MCP->>CaseMgr: 'get_item(42, 0)' + CaseMgr-->>MCP: '{lucene_id: 12345}' + MCP-->>OC: '[[12345-ch42]]' + OC->>LLM: 'tool_result: [[12345-ch42]]' + LLM->>OC: 'text: "The file [[12345-ch42]] contains..."' + OC-->>AgentSvc: 'text: "The file [[12345-ch42]]..."' +end + +AgentSvc->>AgentSvc: extractTextFromJsonLines() +AgentSvc-->>ChatArea: uiCallback(text with citation tokens) +ChatArea->>ChatArea: enqueueStreamingToken(token) + +Note over Renderer: Timer tick renders markdown +ChatArea->>Renderer: renderDraft(assistantDraft) +Renderer->>Renderer: Parse [[hash-chunkId]] tokens +Renderer->>Renderer: appendToken with attributes + +Note over ChatArea: User sees clickable citation link + +User->>ChatArea: click citation token [[12345-ch42]] +ChatArea->>ChatArea: Read TOKEN_HASH_ATTRIBUTE=12345 +ChatArea->>ChatArea: Read TOKEN_CHUNK_ID_ATTRIBUTE=42 +ChatArea->>Controller: navigationCallback("12345", "42") +Controller->>IPED: search by hash ("hash:12345") +IPED-->>Controller: luceneId +Controller->>IPED: setElementIDToScroll("42") +Controller->>IPED: new FileProcessor(luceneId, true).execute() +Controller->>IPED: selectItemInResultsTable(luceneId) +
+
+
+ + + + +
+

Flow 7: Agent Error Recovery

+

+ Agent errors can occur at three points: opencode process fails to start, + the process exits with a non-zero code, or an exception occurs during + execution. The error handler salvages any partial draft and appends a + system error message. Unlike the chatbot flow, there is no + initialization cache to invalidate. +

+
+
+sequenceDiagram +autonumber +participant Controller as AIAssistantController +participant Coord as AIChatCoordinator +participant AgentSvc as OpenCodeAgentService +participant ChatArea as ChatAreaPanel +participant ConvMgr as ConversationManager +participant Sidebar as SidebarPanel +participant MainPanel as AIAssistantPanel + +Controller->>Coord: askAgentQuestion(...) + +Coord->>AgentSvc: askAgentQuestion(...) + +alt opencode process fails to start (exception) + AgentSvc--xController: uiCallback("Agent Error: <exception>") + AgentSvc--xController: onError("Agent Error: <exception>") + rect rgb(255, 240, 245) + Note over Controller: Salvage partial draft + Controller->>ChatArea: salvageStreamingDraft() + Note right of ChatArea: Internally calls commitDraft() or discardDraft() + ChatArea-->>Controller: partialDraft (or null) + alt partialDraft != null + Controller->>ConvMgr: addMessageToActive(partialDraft) + Controller->>Sidebar: updateConversationsList(conversations) + end + end + Controller->>ConvMgr: addMessageToActive(systemErrorMessage) + Controller->>MainPanel: setProcessing(false) + +else process exits with non-zero exit code + AgentSvc->>AgentSvc: process.waitFor() returns exitCode != 0 + AgentSvc-->>Controller: uiCallback("[Agent Error]: process terminated with error code: N") + AgentSvc-->>Controller: onError("The opencode process terminated with error code: N") + rect rgb(255, 240, 245) + Controller->>ChatArea: salvageStreamingDraft() + ChatArea-->>Controller: partialDraft (or null) + alt partialDraft != null + Controller->>ConvMgr: addMessageToActive(partialDraft) + Controller->>Sidebar: updateConversationsList(conversations) + end + end + Controller->>ConvMgr: addMessageToActive(systemErrorMessage) + Controller->>MainPanel: setProcessing(false) + +else process exits normally but with partial output + AgentSvc->>AgentSvc: exitCode == 0 + AgentSvc->>AgentSvc: chatHistory.add(user + assistant turns) + AgentSvc-->>Controller: onComplete() + Controller->>ChatArea: pruneStreaming(onDrained) + alt assistantDraft has content + Controller->>ConvMgr: addMessageToActive(assistantDraft) + end + Controller->>MainPanel: setProcessing(false) +end +
+
+
+ + + + +
+

Flow 8: Switch Agent Conversation

+

+ Switching to a previous agent conversation restores the chat messages and + the sessionId. No background context restoration occurs + (agent conversations don't use the file context list). The coordinator + restores chat history for multi-turn awareness. +

+
+
+sequenceDiagram +autonumber +actor User +participant Sidebar as SidebarPanel +participant Controller as AIAssistantController +participant ConvMgr as ConversationManager +participant Coord as AIChatCoordinator +participant ChatArea as ChatAreaPanel +participant MainPanel as AIAssistantPanel + +User->>Sidebar: select agent conversation +Sidebar->>Controller: onConversationSelected(conv) +Controller->>Controller: isSwitchingChats = true +Controller->>ConvMgr: setActiveConversation(conv) +Controller->>MainPanel: setContextPanelVisible(false) + +Controller->>Coord: loadHistoricalContext(null, null, conv.messages) +Note over Coord: Restores chatHistory (user + assistant turns only) +Note over Coord: Restores agentService chatHistory + +Controller->>Controller: isSwitchingChats = false +Controller->>ChatArea: forceDiscardStreaming() +Controller->>Controller: refreshChatArea() +Controller->>Sidebar: setSelectedValue(conv) + +Note over Controller: Agent path returns early. +Note over Controller: No background context restoration. +
+
+
+ + + + +
+

Flow 9: Delete Agent Conversation

+

+ Deletion follows the same soft-delete pattern as standard conversations. + The file is saved with status = "deleted" and preserved on + disk. Agent files use the agent_ prefix. +

+
+
+sequenceDiagram +autonumber +actor User +participant Sidebar as SidebarPanel +participant Controller as AIAssistantController +participant Persist as ConversationPersistence +participant ConvMgr as ConversationManager +participant ChatArea as ChatAreaPanel +participant CtxMgr as AIContextManager + +User->>Sidebar: click X on agent chat row +Sidebar->>Controller: onDeleteRequested(conv) +Controller->>Persist: deleteConversation(conv) +Persist->>Persist: conv.setStatus("deleted") +Persist->>Persist: saveConversation(conv) +Note over Persist: File: agent_[uuid].json (preserved on disk) + +Controller->>ConvMgr: removeConversation(conv) + +alt deleted active chat + Controller->>ConvMgr: getConversations() + alt remaining conversations exist + Controller->>ConvMgr: setActiveConversation(remaining.get(0)) + Controller->>Controller: loadConversation(remaining.get(0)) + else no conversations remain + Controller->>ConvMgr: setActiveConversation(null) + Controller->>Controller: clearChatScreenAndMemory() + Controller->>CtxMgr: clearContext() + Controller->>Controller: refreshChatArea() + end +end + +Controller->>Sidebar: updateConversationsList(conversations) +
+
+
+ + + diff --git a/iped-app/src/main/java/iped/app/ui/ai/documentation/IPED Chatbot sequence diagrams.html b/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot sequence diagrams.html similarity index 100% rename from iped-app/src/main/java/iped/app/ui/ai/documentation/IPED Chatbot sequence diagrams.html rename to iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot sequence diagrams.html From 0a5fdb74652181374db2d49e9e90dd7df616b886 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Tue, 14 Jul 2026 13:23:40 -0300 Subject: [PATCH 133/143] doc(chatbot): adds comprehensive technical documentation on the chatbot feature --- .../agent/Agent sequence diagrams.html | 4 +- ...tbot Complete Technical Documentation.html | 1281 +++++++++++++++++ .../chatbot/Chatbot sequence diagrams.html | 10 +- 3 files changed, 1291 insertions(+), 4 deletions(-) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot Complete Technical Documentation.html diff --git a/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html b/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html index b373f7dd0e..46489e119b 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html +++ b/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html @@ -3,7 +3,7 @@ - IPED Agent Feature Architecture + Agent Sequence Diagrams + + + + + +

Chatbot Complete Technical Documentation

+

+ Internal developer documentation — IPED AI Assistant Module — July 2026 +

+ + + + + + + + + +
+

1. Overview

+
+

+ The IPED AI Assistant Chatbot is a conversational interface integrated into + IPED, a digital forensics application. It allows investigators to select + WhatsApp chat exports from a forensic case and ask natural-language questions + about their content. The LLM processes the chat data and returns answers + with clickable citations that navigate directly to the referenced items in + IPED's viewer. +

+

+ The chatbot handles three distinct data-volume scenarios: a single chat, + multiple chats below a chunk threshold (where raw HTML is sent), and multiple + chats above the threshold (where pre-computed summaries are sent instead). + This adaptive strategy balances answer quality against LLM context-window + limits and memory consumption. +

+
+
+ + + + +
+

2. Motivation

+
+

+ Digital forensic investigations frequently involve large volumes of + messaging data. Manually reviewing WhatsApp chat exports is time-consuming + and error-prone. The chatbot addresses this by enabling investigators to + query chat content using natural language, receiving answers backed by + citations that link to the original forensic evidence. +

+

+ The architecture had to solve several practical constraints: +

+
    +
  • Variable data volume: A single WhatsApp export can range + from a few messages to tens of thousands. Multiple chats compound this.
  • +
  • LLM context-window limits: Sending raw HTML for many + chats simultaneously would exceed the model's token capacity.
  • +
  • Memory safety: The backend must avoid unbounded memory + growth when processing large inputs.
  • +
  • Response quality: Summaries must preserve enough detail + for the LLM to answer accurately while fitting within the context window.
  • +
  • Offline operation: The system operates entirely + locally within IPED's forensic environment, with no external API calls.
  • +
+

+ These constraints drove the three-path routing strategy documented below. +

+
+
+ + + + +
+

3. High-Level Architecture

+
+

+ The chatbot follows a layered architecture with clear separation of + concerns. Five logical layers collaborate to process user queries: +

+ +
+
+

View Layer

+

AIAssistantPanel — top-level JFrame container and layout manager.

+

SidebarPanel — conversation list, new-chat button, delete.

+

ContextPanel — file context list with per-item removal and summarized-mode indicator.

+

ChatAreaPanel — chat rendering, text input, streaming orchestration, citation click handling.

+

AIMarkdownRenderer — markdown-to-StyledDocument parser, thinking blocks, citation tokens.

+

ChatStreamAnimator — timer-based teletype animation engine.

+
+ +
+

Controller / Orchestrator Layer

+

AIAssistantController — orchestrates event routing, view updates, chat lifecycle, and persistence.

+

AIChatCoordinator — central pipeline: validates context, selects backend endpoint, manages session hashes, streams responses.

+
+ +
+

Payload / Extraction Layer

+

AIPayloadFactory — translates ContextFileEntry lists into backend DTOs.

+

AIWhatsappChatExtractor — reads IPED items into UTF-8 HTML strings.

+

SummaryValueExtractor — extracts pre-computed AI summaries from item metadata.

+

ContextItemValidator — rejects non-WhatsApp items, empty files, empty communications.

+
+ +
+

Network / Backend Layer

+

AIBackendClient — HTTP/SSE client implementing AIBackendService. Six endpoints.

+

AIBackendConfig — immutable baseUrl + apiKey.

+

DTOsAIInitChatRequest, AIStreamChatRequest, AIInitMultiChatRequest, AIInitMultiChatFullRequest, AIMultiChatStreamRequest.

+
+ +
+

State Layer

+

AIContextManager — singleton, thread-safe context file list with change-event broadcasting.

+

ConversationManager — singleton, active conversation state, auto-title generation.

+

ConversationPersistence — JSON serialization to <case_dir>/iped/data/ai_chats/.

+
+
+ +
+
+graph TB + subgraph View["View Layer"] + AP[AIAssistantPanel] + SP[SidebarPanel] + CP[ContextPanel] + CA[ChatAreaPanel] + MR[AIMarkdownRenderer] + CSA[ChatStreamAnimator] + end + + subgraph Controller["Controller / Orchestrator"] + AIC[AIAssistantController] + ACC[AIChatCoordinator] + end + + subgraph Payload["Payload / Extraction"] + APF[AIPayloadFactory] + WCE[AIWhatsappChatExtractor] + SVE[SummaryValueExtractor] + CIV[ContextItemValidator] + end + + subgraph Network["Network / Backend"] + ABC[AIBackendClient] + ABCfg[AIBackendConfig] + end + + subgraph State["State Layer"] + CtxM[AIContextManager] + CM[ConversationManager] + CP2[ConversationPersistence] + end + + AIC --> ACC + ACC --> APF + ACC --> ABC + APF --> WCE + APF --> SVE + AIC --> CtxM + AIC --> CM + CM --> CP2 + CA --> CSA + CA --> MR + AIC --> CA + AIC --> SP + AIC --> CP +
+
+
+
+ + + + +
+

4. Main Components and Responsibilities

+
+ +

4.1 AIAssistantController

+

+ The central controller (AIAssistantController.java) owns all + UI event routing. It wires the send button and Enter key to + handleSendAction(), manages conversation creation and deletion, + handles context-add events (including the auto-fork flow), and coordinates + persistence after every meaningful state change. It is the only class that + directly manipulates both the view and the coordinator. +

+ +

4.2 AIChatCoordinator

+

+ The coordinator (AIChatCoordinator.java) is the pipeline engine. + It validates context entries, computes the total chunk count, decides which + backend endpoint to call, uploads chat data (or summaries) on a background + thread, streams the response back through callbacks, and maintains the + session hash cache so that subsequent questions skip re-initialization. +

+ +

4.3 AIBackendClient

+

+ The HTTP client (AIBackendClient.java) implements the + AIBackendService interface and handles all network communication. + Initialization endpoints (/api/init_chat, + /api/init_multichat_with_summaries, + /api/init_multichat_full) return synchronously. Streaming + endpoints use Server-Sent Events (SSE) over HTTP/1.1, parsed by the shared + processSseStream() helper. +

+ +

4.4 AIPayloadFactory

+

+ The factory (AIPayloadFactory.java) translates IPED's internal + ContextFileEntry objects into network-safe DTOs. It enforces a + strict contract: buildMultiChatRequest() requires pre-computed + summaries and silently skips entries that lack them; + buildMultiChatFullRequest() extracts raw HTML. The factory also + adapts IPED's flexible metadata types (String, + Collection, Object[]) into clean lists. +

+ +

4.5 AIContextManager

+

+ A singleton (AIContextManager.java) that maintains the list of + files the user has added to the current conversation's context. It uses a + CopyOnWriteArrayList for thread-safe read-heavy access, validates + items via ContextItemValidator, and fires + ContextChangeEvent notifications on the EDT. +

+ +

4.6 ConversationManager

+

+ A singleton (ConversationManager.java) that tracks the active + conversation, manages the conversation list, and auto-generates titles from + the first user message (truncated to 30 characters). Every + addMessageToActive() call triggers an asynchronous save to disk. +

+ +

4.7 ConversationPersistence

+

+ Handles JSON serialization to <case_dir>/iped/data/ai_chats/. + Chatbot conversations use the chat_ prefix; agent conversations + use agent_. Deletion is soft: the file is rewritten with + status = "deleted" but remains on disk for auditability. +

+ +

4.8 Streaming Subsystem

+

+ The streaming subsystem comprises three classes working together: +

+
    +
  • ChatAreaPanel — orchestrates streaming: starts it via + startMessageStreaming(), feeds tokens via + enqueueStreamingToken(), drains via + pruneStreaming(), and salvages partial drafts on error via + salvageStreamingDraft().
  • +
  • ChatStreamAnimator — a Swing Timer + firing every 30ms, dequeuing one whitespace-delimited token per tick and + appending it to the draft message.
  • +
  • AIMarkdownRenderer — renders the draft into a + StyledDocument on every tick, parsing markdown, thinking blocks, + and citation tokens.
  • +
+ +
+
+ + + + +
+

5. Chat Context Management

+
+

+ Context management governs how WhatsApp chat files enter the system and + how they flow through validation, extraction, and backend upload. +

+ +

5.1 Adding Files to Context

+

+ Users add files by right-clicking items in IPED's results table and selecting + "Add to AI Context." The entry point is + AIAssistantController.addItemsToContext(), which enforces + several rules: +

+
    +
  1. If the system is currently streaming a response, the addition is blocked + with a "please wait" dialog to prevent race conditions.
  2. +
  3. If the active conversation is an agent conversation, context files are + rejected outright.
  4. +
  5. If no active conversation exists, a new one is created via + startNewConversationWithCurrentContext().
  6. +
  7. If the active conversation has no assistant reply yet, files are appended + directly to the AIContextManager.
  8. +
  9. If the conversation already contains an assistant reply (it is "sealed"), + the user is prompted to auto-fork: a new conversation is created that carries + forward the existing context and merges the new files.
  10. +
+ +

5.2 Validation

+

+ Each item is validated by ContextItemValidator.getRejectionReason(), + which checks: +

+
    +
  • The item must be a WhatsApp chat (verified via + AIWhatsappChatExtractor.isWhatsAppChatType()).
  • +
  • The item must not belong to the "Empty Files" category.
  • +
  • The item must not have its communication flagged as empty.
  • +
+

+ Valid items are stored as ContextFileEntry objects in the + AIContextManager's thread-safe list. Invalid items are stored + separately and shown in the context panel with their rejection reason. +

+ +

5.3 The Context-Edit Lock

+

+ Once a conversation has received an assistant reply, its context becomes + "locked" in the UI. This is a deliberate design choice: modifying the + context after the LLM has already processed it would create an inconsistent + state. The lock is enforced both in the UI (the context panel disables + editing) and in the controller (additions trigger the auto-fork flow). +

+ +

5.4 The Auto-Fork Flow

+

+ When a user adds files to a sealed conversation, the auto-fork flow creates + a new StandardConversation that inherits the previous context + IDs and merges the new items. The coordinator's history is cleared, and the + new conversation becomes the active workspace. The previous conversation + remains unchanged and accessible from the sidebar. +

+ +
+
+ + + + +
+

6. Single Chat Workflow

+
+

+ When only one WhatsApp chat file is in context, the chatbot sends the + complete HTML representation of that chat to the backend. This is the + highest-fidelity path: the LLM sees every message, every timestamp, and + every media reference. +

+ +

6.1 Execution Flow

+ +
+
+sequenceDiagram + autonumber + participant User + participant Ctrl as AIAssistantController + participant Coord as AIChatCoordinator + participant Ext as AIWhatsappChatExtractor + participant Back as AIBackendClient + participant Anim as ChatStreamAnimator + participant Ren as AIMarkdownRenderer + + User->>Ctrl: type question, press Send + Ctrl->>Ctrl: handleSendAction() + Ctrl->>Ctrl: addMessage("You", text, "user") + Ctrl->>Ctrl: startMessageStreaming(assistantDraft) + Ctrl->>Coord: askQuestion(text, uiCb, completeCb, errorCb) + + Note over Coord: Background thread starts + + Coord->>Coord: getContextEntriesForUI() → filter valid + Coord->>Coord: calculateTotalChunks(validEntries) → 1 + + Coord->>Ext: extractHtml(item) + Ext-->>Coord: complete HTML string + + Coord->>Back: initChat(html) + Back->>Back: POST /api/init_chat + Back-->>Coord: chatHash + + Coord->>Back: streamChatResponse(hash, question, history, cb) + Back->>Back: POST /api/chat/stream (SSE) + + loop each SSE token + Back-->>Coord: token + Coord-->>Ctrl: uiCallback(token) via EDT + Ctrl->>Anim: enqueueStreamingToken(token) + Anim->>Ren: renderDraft(assistantDraft) + end + + Coord-->>Ctrl: onComplete() + Ctrl->>Ctrl: pruneStreaming → commitDraft → addMessageToActive + Ctrl->>Ctrl: setProcessing(false) +
+
+ +

6.2 Key Details

+
    +
  • HTML extraction: AIWhatsappChatExtractor.extractHtml() + reads the IPED item's InputStream into a UTF-8 string. It + verifies the item is a WhatsApp chat type before reading.
  • +
  • Initialization: The HTML is wrapped in an + AIInitChatRequest ({"chat_html": "..."}) and sent + to /api/init_chat. The backend returns a hash that identifies + the cached session.
  • +
  • Session caching: The hash is stored in + currentChatHashes and persisted to the conversation object. On + subsequent questions with the same context, re-initialization is skipped.
  • +
+ +
+
+ + + + +
+

7. Multi-Chat Workflow

+
+

+ When two or more WhatsApp chat files are in context, the system must decide + between two sub-paths based on the total chunk count. A "chunk" corresponds + to a CHUNK_IDS metadata entry on the IPED item, which the + backend's summarization pipeline produced during case processing. +

+ +

7.1 Below Threshold: Multi-Chat Full

+
+ Condition: validEntries.size() > 1 AND + totalChunks ≤ 10 +
+

+ When the combined chunk count is small (10 or fewer), the system sends the + raw HTML of every chat to the backend. This preserves full fidelity across + all conversations. +

+

+ AIPayloadFactory.buildMultiChatFullRequest() iterates over + valid entries, calls extractHtml() for each, and returns an + AIInitMultiChatFullRequest containing + {"chat_contents": ["html1", "html2", ...]}. +

+

+ The backend endpoint is /api/init_multichat_full, which + processes each HTML independently and returns an array of hashes. The + streaming endpoint is /api/multichat_full/stream. +

+ +

7.2 Above Threshold: Multi-Chat Summarized

+
+ Condition: validEntries.size() > 1 AND + totalChunks > 10 +
+

+ When the combined chunk count exceeds 10, sending raw HTML would overwhelm + the LLM's context window. Instead, the system sends pre-computed summaries. +

+

+ AIPayloadFactory.buildMultiChatRequest() extracts summaries + from each entry's metadata (ExtraProperties.SUMMARY) and + summary IDs (ExtraProperties.CHUNK_IDS). Files without + summaries are silently skipped. The resulting + AIInitMultiChatRequest contains: +

+
{
+  "summarized_chats": [
+    {
+      "chat_id": "abc123",
+      "chat_name": "WhatsApp Chat with John.txt",
+      "summaries": ["summary text chunk 1", "summary text chunk 2"],
+      "summary_ids": ["chunk_0", "chunk_1"]
+    }
+  ]
+}
+

+ The backend endpoint is /api/init_multichat_with_summaries. + The streaming endpoint is /api/multichat/stream. +

+ +

7.3 Summaries are Pre-Computed

+
+ Important: Summaries are not generated at request time. + They are produced by IPED's summarization pipeline during case processing + and stored in each item's ExtraProperties.SUMMARY metadata. + The chatbot merely reads and forwards them. +
+ +

7.4 Fallback Chain for Summaries

+

+ AIPayloadFactory implements a resilient fallback chain for + summary extraction: +

+
    +
  1. First, try ExtraProperties.SUMMARY from runtime + ExtraAttributes (in-memory).
  2. +
  3. If empty, fall back to the same key from Lucene stored metadata + (on disk).
  4. +
  5. If still empty, fall back to entry.getSummary(), which + is the UI-cached summary from the ContextFileEntry.
  6. +
+

+ For summary IDs, if the metadata is missing or the count does not match the + number of summaries, synthetic IDs (summary_fallback_1, + summary_fallback_2, ...) are generated to maintain a 1:1 + mapping and prevent the citation engine from crashing. +

+ +
+
+ + + + +
+

8. Endpoint Selection Logic

+
+

+ The endpoint selection is a deterministic three-way branch computed inside + AIChatCoordinator.askQuestion(). The decision is based on two + variables: the number of valid context entries and the total chunk count. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ConditionInit EndpointStream EndpointPayload Factory
1 valid entryPOST /api/init_chatPOST /api/chat/streamDirect extractHtml()
≥2 entries, totalChunks ≤ 10POST /api/init_multichat_fullPOST /api/multichat_full/streambuildMultiChatFullRequest()
≥2 entries, totalChunks > 10POST /api/init_multichat_with_summariesPOST /api/multichat/streambuildMultiChatRequest()
+ +

8.1 The Threshold: Why 10 Chunks?

+

+ The threshold of 10 chunks is a pragmatic balance point. Below this limit, + the total HTML content from multiple chats typically fits within the LLM's + context window without truncation. Above it, the risk of exceeding token + limits grows rapidly, and the marginal value of raw content diminishes + compared to pre-computed summaries that capture the essential information. +

+ +

8.2 When Initialization is Skipped

+

+ The coordinator caches currentChatHashes and + currentContextItemIds. On each question, it checks whether the + context has changed or the hashes are empty. If neither condition is true, + initialization is skipped entirely and the question is streamed directly + using the existing hashes. This avoids redundant HTTP calls and backend + re-processing for follow-up questions within the same context. +

+
boolean contextChanged = !newContextIds.equals(currentContextItemIds);
+boolean needsInitialization = contextChanged || currentChatHashes.isEmpty();
+ +

8.3 Error Recovery: Hash Invalidation

+

+ If the backend returns a "not found" error (indicating its cache was + cleared, e.g., after a restart), the coordinator clears + currentChatHashes, forcing re-initialization on the next + attempt. This ensures the system is self-healing without user intervention. +

+ +
+
+ + + + +
+

9. Request Lifecycle

+
+

+ Every user question follows a well-defined lifecycle from keystroke to + rendered response. The lifecycle spans multiple threads and involves + careful coordination between Swing's EDT and background processing threads. +

+ +

9.1 Complete Lifecycle Diagram

+ +
+
+graph TD + A["User clicks Send / presses Enter"] --> B["handleSendAction()"] + B --> C{"text empty?"} + C -- yes --> Z["return (no-op)"] + C -- no --> D["ensureChatServiceInitialized()"] + D --> E{"active conversation?"} + E -- no --> F["startNewConversation()"] + E -- yes --> G["addMessage('You', text, 'user')"] + F --> G + G --> H["clear input, setProcessing(true)"] + H --> I["startMessageStreaming(assistantDraft)"] + I --> J["coordinator.askQuestion()"] + J --> K["Background thread starts"] + K --> L["getContextEntriesForUI(), filter valid"] + L --> M{"validEntries empty?"} + M -- yes --> N["onError('add at least one file')"] + M -- no --> O["calculateTotalChunks()"] + O --> P{"needsInitialization?"} + P -- no --> S["Stream response"] + P -- yes --> Q{"Route: single / multi-full / multi-summarized"} + Q --> R["initChat / initMultiChatFull / initMultiChat"] + R --> S + S --> T["processSseStream()"] + T --> U["token → uiCallback → EDT → enqueueStreamingToken"] + U --> V["ChatStreamAnimator timer → renderDraft"] + V --> T + S --> W["onComplete()"] + W --> X["pruneStreaming → commitDraft → addMessageToActive"] + X --> Y["setProcessing(false)"] +
+
+ +

9.2 Thread Model

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ThreadResponsibility
EDT (Event Dispatch Thread)All Swing UI operations: reading input, rendering messages, + updating panels, dispatching callbacks via + SwingUtilities.invokeLater()
Background thread (anonymous)Backend initialization, HTTP requests, SSE stream reading, + token callback invocation
Swing Timer (30ms)ChatStreamAnimator's teletype animation: dequeues tokens and + triggers re-renders
Async save threadsConversationPersistence writes to disk without blocking the UI
+ +
+
+ + + + +
+

10. Response Processing

+
+

+ Response processing converts raw SSE events from the backend into rendered + markdown in the chat panel. This involves three stages: SSE parsing, + token animation, and markdown rendering. +

+ +

10.1 SSE Event Parsing

+

+ The AIBackendClient.processSseStream() method reads + data: lines from the HTTP input stream. Each JSON payload has + a type field that determines how the content is formatted: +

+ + + + + + + + + + + + +
SSE TypeBehavior
thinkingWrapped in [[AI_THINKING]]...[[/AI_THINKING]] markers for collapsible rendering.
thinking_doneCloses the thinking block.
statusFormatted as italic markdown: _[Status]: content_
finalFirst token gets **[FINAL ANSWER]:** prefix; subsequent tokens pass through.
errorThrows AIBackendException.
[DONE] or emptyIgnored (keep-alive / termination signal).
+ +

10.2 Token Animation

+

+ Tokens flow from the background thread to the EDT via + SwingUtilities.invokeLater(). On the EDT, + ChatAreaPanel.enqueueStreamingToken() forwards the token to + ChatStreamAnimator.enqueueToken(), which splits it into + whitespace-delimited parts using the regex \S+|\s+ and adds + them to an internal queue. +

+

+ A Swing Timer fires every 30ms. Each tick pops one part from + the queue, appends it to the draft message via + AIChatMessage.appendContent(), and triggers + AIMarkdownRenderer.renderDraft(). +

+ +

10.3 Markdown Rendering

+

+ AIMarkdownRenderer renders the full draft on every tick. It + first removes the previous draft range from the StyledDocument, + then re-renders the complete message. The renderer supports: +

+
    +
  • Headings (#) — bold, 14pt, blue
  • +
  • Bold (**text**) and italic (*text*)
  • +
  • Blockquotes (> text) — gray foreground
  • +
  • Lists (- , * , 1. ) — bullet prefix
  • +
  • Citations (<<hash-chunkId>>) — blue underlined links
  • +
  • Thinking blocks ([[AI_THINKING]]...[[/AI_THINKING]]) — collapsible
  • +
+ +

10.4 Draft Lifecycle

+

+ The draft goes through three states: +

+
    +
  1. Streaming: The draft is actively being appended to. + renderDraft() re-renders it on every tick.
  2. +
  3. Committed: commitDraft() finalizes the + rendered content, making it permanent in the document.
  4. +
  5. Discarded: discardDraft() removes the + draft range from the document (used when the response is empty or on error).
  6. +
+ +
+
+ + + + +
+

11. Citation Generation

+
+

+ Citations allow the LLM to reference specific forensic items in its + responses. When a user clicks a citation, IPED navigates to the referenced + item and opens it in the viewer. +

+ +

11.1 Citation Token Format

+

+ Citations use the format <<hash-chunkId>>, where: +

+
    +
  • hash is the content hash of the source file (used to + search IPED's Lucene index).
  • +
  • chunkId is the HTML element ID within the chat viewer, + used for scroll-to-position.
  • +
+ +

11.2 Token Rendering

+

+ When AIMarkdownRenderer encounters << in + the response text, it scans for the closing >>, splits + on the first -, and creates a styled token with metadata + attributes: +

+
    +
  • TOKEN_ATTRIBUTE = Boolean.TRUE
  • +
  • TOKEN_HASH_ATTRIBUTE = the hash string
  • +
  • TOKEN_CHUNK_ID_ATTRIBUTE = the chunk ID string
  • +
+

+ The visible text is resolved via resolveTokenVisibleText(), + which looks up the hash in AIContextManager.getContextEntriesForUI() + and displays the filename if a match is found. Otherwise, the raw + <<hash-chunkId>> string is shown. +

+ +

11.3 Citation Click Handling

+

+ When a user clicks a citation token, ChatAreaPanel's mouse + listener reads the token attributes from the styled document element at the + click offset and invokes the navigationCallback. This calls + AIAssistantController.navigateToItem(), which: +

+
    +
  1. Searches IPED's Lucene index with hash: + the hash value.
  2. +
  3. Gets the matching IItemId.
  4. +
  5. If chunkId is non-empty, tells the HTML viewer to scroll + to that element.
  6. +
  7. Creates a FileProcessor and executes it to open the file + in the viewer.
  8. +
  9. Selects the item in the results table.
  10. +
+ +
+ Design note: Citations work for both chatbot and agent + features. The chatbot's backend produces citations from the HTML chat + content during summarization. The agent's MCP tools can produce citations + from search and document-read results (though this path is still in early + stages). +
+ +
+
+ + + + +
+

12. Sequence Diagram Explanation

+
+

+ A detailed Mermaid sequence diagram covering all 12 flows of the chatbot + feature is maintained as a separate document: +

+

+ + Chatbot sequence diagrams.html + +

+

+ That document covers: App Boot, Add File to Context, Auto-Fork, Send + Question / Streaming, Backend Recovery, Auto-Generate Title, Quick Actions, + Switch Conversation, Click Citation, Start New Chat, Persistence, and + Delete Conversation. Each flow includes a self-contained Mermaid diagram + with numbered steps. +

+

+ The following simplified diagram shows the core send-and-stream path that + ties together all three backend routes: +

+ +
+
+sequenceDiagram + autonumber + participant Ctrl as Controller + participant Coord as Coordinator + participant Back as BackendClient + participant Anim as Animator + participant Ren as Renderer + + Ctrl->>Coord: askQuestion(text, callbacks) + Coord->>Coord: calculateTotalChunks() + + alt Single Chat (1 file) + Coord->>Back: initChat(html) → hash + Coord->>Back: streamChatResponse(hash, q, hist) + else Multi-Chat Full (≤10 chunks) + Coord->>Back: initMultiChatFull(htmls) → hashes + Coord->>Back: streamMultiChatFullResponse(hashes, q, hist) + else Multi-Chat Summarized (>10 chunks) + Coord->>Back: initMultiChat(summaries) → hashes + Coord->>Back: streamMultiChatResponse(hashes, q, hist) + end + + loop SSE stream + Back-->>Ctrl: token + Ctrl->>Anim: enqueueToken() + Anim->>Ren: renderDraft() + end + + Coord-->>Ctrl: onComplete() + Ctrl->>Ctrl: commitDraft → save +
+
+ +
+
+ + + + +
+

13. Architectural Decisions and Trade-offs

+
+ +

13.1 Why Three Endpoints Instead of One

+

+ A single endpoint that accepts both HTML and summaries would simplify the + client but would complicate the backend: it would need to inspect the + payload type, branch on content format, and manage two distinct processing + pipelines behind a single URL. Separating the endpoints makes the backend + stateless and deterministic. The client pays a small cost in routing + logic, but the backend remains simple and testable. +

+ +

13.2 Why HTML for Small Contexts

+

+ Pre-computed summaries lose nuance. For a single chat or a small collection, + the full HTML preserves every message, every timestamp, and every media + reference. This maximizes answer quality when the data fits within the + context window. The threshold of 10 chunks is the empirical boundary where + raw content typically fits without truncation. +

+ +

13.3 Why Summaries for Large Contexts

+

+ Sending raw HTML for 20+ chats would exceed the LLM's token limit and + cause the backend to crash or silently truncate. Summaries compress the + essential information into a fraction of the tokens while preserving + the chat structure and key events. The trade-off is reduced detail, but + this is acceptable for multi-chat queries where the user is typically + asking broad questions across conversations. +

+ +

13.4 Why Session Hash Caching

+

+ Backend initialization (parsing HTML, building vector embeddings, indexing) + is expensive. Caching the hash avoids re-processing when the user asks + follow-up questions within the same context. The cache is invalidated + automatically when the context changes or when the backend reports a + "not found" error. +

+ +

13.5 Why Auto-Fork Instead of In-Place Context Editing

+

+ Modifying the context after the LLM has processed it would create an + inconsistent state: the backend's cached session would reflect the old + context, but the UI would show the new one. Auto-fork avoids this by + creating a clean break: the new conversation starts fresh with the merged + context, and the old conversation remains as a historical record. +

+ +

13.6 Why Soft Delete

+

+ Forensic data must be preserved for auditability. Soft-deleting conversations + (rewriting the JSON with status = "deleted") ensures the file + remains on disk for chain-of-custody purposes while being hidden from the + UI. +

+ +

13.7 Error Recovery Philosophy

+

+ The coordinator preserves initialization state during streaming failures. If + streaming fails after a successful init, the hashes are kept so the user + can retry without re-uploading. Only initialization failures clear the + cache. This design prioritizes resilience: partial progress is never lost + unless the initialization itself failed. +

+ +
+
+ + + + +
+

14. Extensibility Considerations

+
+ +

14.1 Adding New Chat Types

+

+ The ContextItemValidator currently rejects non-WhatsApp items. + To support Telegram, SMS, or other messaging formats, you would: +

+
    +
  1. Create a new extractor (e.g., AITelegramChatExtractor) + parallel to AIWhatsappChatExtractor.
  2. +
  3. Update ContextItemValidator.getRejectionReason() to accept + the new media type.
  4. +
  5. Update AIPayloadFactory to route to the correct extractor.
  6. +
+

+ The rest of the pipeline (validation, context management, backend + communication, streaming) is format-agnostic and would require no changes. +

+ +

14.2 Adding New Backend Endpoints

+

+ The AIBackendService interface defines six methods. Adding a + new endpoint requires: +

+
    +
  1. Adding a method to the interface.
  2. +
  3. Implementing it in AIBackendClient.
  4. +
  5. Adding the routing logic in AIChatCoordinator.askQuestion().
  6. +
+ +

14.3 Changing the Chunk Threshold

+

+ The threshold of 10 chunks is hardcoded in + AIChatCoordinator.askQuestion() and in the UI's + setSummarizedMode() call. Making it configurable would + require extracting it to AIBackendConfig or a system property. +

+ +

14.4 Adding New Citation Formats

+

+ The <<hash-chunkId>> format is parsed by + AIMarkdownRenderer.appendInlineMarkdown(). Changing the format + would require updating the regex scan and the token-attribute storage, as + well as the click handler in ChatAreaPanel. +

+ +

14.5 Streaming Protocol Changes

+

+ If the backend switches from SSE to WebSocket or another streaming protocol, + only AIBackendClient.processSseStream() and the corresponding + streamChatResponse() / streamMultiChatResponse() + methods need updating. The rest of the system consumes tokens through the + same Consumer<String> callback interface. +

+ +
+
+ + + + +
+

15. Relevant Source Files

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FilePackageRole
AIAssistantController.javaiped.app.ui.ai.controllerMain controller, event routing, chat lifecycle
AIChatCoordinator.javaiped.app.ui.aiPipeline orchestrator, endpoint selection, session caching
AIBackendClient.javaiped.app.ui.ai.backendHTTP/SSE client, SSE parsing
AIBackendService.javaiped.app.ui.ai.backendInterface defining 6 backend endpoints
AIBackendConfig.javaiped.app.ui.ai.backendImmutable baseUrl + apiKey config
AIBackendException.javaiped.app.ui.ai.backendChecked exception for backend errors
AIInitChatRequest.javaiped.app.ui.ai.backendSingle-chat init DTO
AIStreamChatRequest.javaiped.app.ui.ai.backendStream request DTO with AIMessage inner class
AIInitMultiChatRequest.javaiped.app.ui.ai.backendSummarized multi-chat init DTO
AIInitMultiChatFullRequest.javaiped.app.ui.ai.backendFull HTML multi-chat init DTO
AIMultiChatStreamRequest.javaiped.app.ui.ai.backendMulti-chat stream request DTO
AIPayloadFactory.javaiped.app.ui.ai.utilBuilds DTOs from ContextFileEntry lists
AIWhatsappChatExtractor.javaiped.app.ui.ai.utilReads IPED items into UTF-8 HTML
SummaryValueExtractor.javaiped.app.ui.ai.utilExtracts summaries from item metadata
ContextItemValidator.javaiped.app.ui.ai.utilValidates items for context inclusion
ConversationPersistence.javaiped.app.ui.ai.utilJSON serialization to/from disk
ContextFileEntry.javaiped.app.ui.ai.modelWraps IItem with validation state and summary
AIChatMessage.javaiped.app.ui.ai.modelUI chat message model
StandardConversation.javaiped.app.ui.ai.modelChatbot conversation with chatHashes and contextIds
AgentConversation.javaiped.app.ui.ai.modelAgent conversation with sessionId
AIContextManager.javaiped.app.ui.ai.contextThread-safe context file list
ConversationManager.javaiped.app.ui.ai.contextActive conversation, auto-title, conversation list
ChatAreaPanel.javaiped.app.ui.ai.viewChat rendering, streaming orchestration, citation clicks
ChatStreamAnimator.javaiped.app.ui.ai.viewTimer-based teletype animation
AIMarkdownRenderer.javaiped.app.ui.ai.viewMarkdown-to-StyledDocument, citations, thinking blocks
+ +
+
+ + + + +
+

16. Conclusion

+
+

+ The IPED AI Assistant Chatbot is designed around a central trade-off: + answer quality versus data volume. For small contexts, raw HTML provides the + LLM with complete information. For large contexts, pre-computed summaries + prevent token overflow while preserving essential information. The three-path + routing strategy, driven by chunk count, makes this adaptation automatic + and transparent to the user. +

+

+ The architecture separates concerns cleanly: the view layer handles + rendering, the controller manages lifecycle, the coordinator orchestrates + the pipeline, the payload factory translates data formats, and the network + client handles HTTP communication. This separation makes each component + independently testable and replaceable. +

+

+ Error recovery is designed to be self-healing: cached hashes are preserved + during streaming failures and invalidated automatically on backend cache + misses. Partial drafts are salvaged on error so the user never loses the + LLM's partial response. +

+

+ The citation system bridges the gap between the LLM's text output and + IPED's forensic navigation, enabling investigators to move seamlessly from + a natural-language answer to the underlying evidence. +

+
+
+ + + diff --git a/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot sequence diagrams.html b/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot sequence diagrams.html index d576d8f7fc..29992d48da 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot sequence diagrams.html +++ b/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot sequence diagrams.html @@ -3,7 +3,7 @@ - IPED AI Assistant Architecture — Corrected + Chatbot Sequence Diagrams + + + + + +

Chatbot Broader Project Report

+

Project Report — Branch: last-branch-documentation — July 2026

+ + + + +
+

Executive Summary

+
+

+ The chatbot feature adds a conversational AI assistant to IPED. Investigators + can select WhatsApp chat exports from a forensic case, add them to an AI + context, and ask natural-language questions. An LLM processes the chat data + and returns answers with clickable citations that navigate directly to the + referenced items in IPED's viewer. +

+

+ Development was structured as a progressive build-up over 157 commits, + evolving from a minimal backend integration contract into a full-featured + assistant with context management, multi-chat routing, streaming responses, + markdown rendering, collapsible thinking blocks, and persistent conversation + history. +

+ +
+
+ ~40 + New Java files +
+
+ ~5,800 + Lines added (Java) +
+
+ 6 + Existing files touched +
+
+ ~95% + Changes inside ai/ +
+
+
+
+ + + + +
+

Where the Changes Live

+
+

+ The vast majority of the work is self-contained within the + iped.app.ui.ai package and its subpackages. Existing IPED code + was touched minimally — just enough to wire the assistant into the + application shell. +

+ +

The AI Package (new)

+
+iped/app/ui/ai/ + agent/ + OpenCodeAgentService.java (agent feature, not chatbot) + backend/ + AIBackendClient.java HTTP/SSE client, 6 endpoints + AIBackendConfig.java baseUrl + apiKey + AIBackendService.java interface contract + AIBackendException.java checked exception + AIInitChatRequest.java single-chat init DTO + AIStreamChatRequest.java stream request DTO + AIInitMultiChatRequest.java summarized multi-chat DTO + AIInitMultiChatFullRequest.java raw HTML multi-chat DTO + AIMultiChatStreamRequest.java multi-chat stream DTO + context/ + AIContextManager.java thread-safe context file list + ConversationManager.java active conversation, auto-title + ContextChangeEvent.java event model + ContextChangeListener.java listener interface + controller/ + AIAssistantController.java main controller, event routing + model/ + AIChatMessage.java chat message model + Conversation.java abstract base + StandardConversation.java chatbot conversation + AgentConversation.java agent conversation + ContextFileEntry.java wraps IItem with validation + util/ + AIPayloadFactory.java builds DTOs from context entries + AIWhatsappChatExtractor.java reads items into HTML + SummaryValueExtractor.java extracts pre-computed summaries + ContextItemValidator.java validates items for context + ConversationPersistence.java JSON save/load to disk + view/ + AIAssistantPanel.java top-level JFrame container + ChatAreaPanel.java chat rendering, streaming + ChatStreamAnimator.java teletype animation engine + AIMarkdownRenderer.java markdown-to-StyledDocument + SidebarPanel.java conversation list + ContextPanel.java file context list + HeaderPanel.java title bar, toggle button + filters/ + AIFiltersLoader.java (moved from ai/ root) + AIFiltersLocalization.java (moved) + AIFiltersTreeCellRenderer.java (moved) + AIFiltersTreeListener.java (moved) + AIFiltersTreeModel.java (moved) +
+ +

Existing Files Modified (outside ai/)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileWhat ChangedWhy
App.javaAdded an AI Assistant toolbar button and a Ctrl+Shift+A keyboard shortcutProvides the entry point to launch the assistant from the main IPED window
MenuClass.javaAdded two right-click menu items: "Add all highlighted to AI context" and "Add all checked to AI context", plus helper methods to resolve items from the results tableEnables users to send forensic items from the results table directly into the AI context
ViewerController.javaAdded a SummaryViewer to the viewer list, excluded it from auto-selection, and added cleanup logic to remove it when no summaries exist in the case indexProvides a viewer tab for AI-generated summaries in the case analysis interface
UICaseDataLoader.javaUpdated import path for AIFiltersLoader (moved to filters subpackage)Package reorganization
UiUtil.javaAdded an overloaded getUIEmptyHtml(String msg) that renders a centered message on the viewer backgroundUsed by SummaryViewer to display summary content in the viewer pane
ExtraProperties.javaAdded SUMMARY and CHUNK_IDS constant definitionsDefines the metadata keys used by the summarization pipeline and the chatbot's routing logic
+ +

Configuration and Resources

+ + + + + + + + + + + + +
FileWhat
IPEDConfig.txtAdded enableAISummarization toggle (default: false)
AISummarizationConfig.txtNew config file for summarization service address, batch sizes, and chat analysis questions
AIFiltersConfig.jsonNew config for AI-specific filter categories
TaskInstaller.xmlRegistered AISummarizationTask in the task pipeline
AISummarizationTask.pyNew 677-line Python task that calls a remote summarization service and stores results in item metadata
Localization files (6 languages)Added 3 keys: AIAssistant.Tooltip, AIAssistant.Title, AIAssistant.Send, plus 2 menu labels
+
+
+ + + + +
+

Architecture at a Glance

+
+

+ The chatbot is built as a layered architecture inside IPED. Five layers + collaborate to process user queries, from button click to rendered answer. +

+ +
+
+graph TB + subgraph UI["User Interface"] + A[Toolbar Button /
Ctrl+Shift+A] + B[Context Panel
file list + status] + C[Chat Panel
messages + input] + D[Sidebar
conversation history] + end + + subgraph Core["Core Logic"] + E[AIAssistantController
event routing] + F[AIChatCoordinator
pipeline engine] + end + + subgraph Data["Data Pipeline"] + G[Context Manager
file validation] + H[Payload Factory
DTO construction] + I[Chat Extractor
HTML extraction] + end + + subgraph Backend["Backend"] + J[AIBackendClient
HTTP / SSE] + K[Python Backend
LLM + summarization] + end + + A --> E + B --> G + C --> E + D --> E + E --> F + F --> G + F --> H + H --> I + F --> J + J -->|HTTP| K + K -->|SSE stream| J +
+
+ +

+ The key insight is that the AI package is self-contained. It plugs into + IPED through three integration points: the toolbar button in + App.java, the right-click context menu in + MenuClass.java, and the summarization viewer in + ViewerController.java. Everything else lives inside the + ai package. +

+ +
+ Design principle: Minimize changes to existing IPED code. + The assistant is an opt-in module that coexists with the rest of the + application without disrupting its core workflows. +
+
+
+ + + + +
+

How It Works (High Level)

+
+ +

1. Setting Up Context

+

+ The user right-clicks one or more WhatsApp chat exports in IPED's results + table and selects "Add to AI context." The items are validated (must be + WhatsApp chats, not empty, not flagged), wrapped in ContextFileEntry + objects, and added to the AIContextManager's thread-safe list. + The context panel in the UI updates to show the selected files. +

+ +

2. Asking a Question

+

+ The user types a question and presses Send. The controller creates a user + message, starts a streaming animation, and delegates to the coordinator. + The coordinator runs on a background thread to avoid blocking the UI. +

+ +

3. Choosing the Right Backend Path

+

+ The coordinator looks at two things: how many files are in context, and how + many "chunks" they contain (a chunk is a segment the summarization pipeline + produced during case processing). Based on this, it picks one of three + backend paths: +

+ +
+
+graph TD + Start["User asks a question"] --> Check{"How many files?"} + Check -->|"1 file"| Single["Send full HTML
to /api/init_chat"] + Check -->|"2+ files"| Chunks{"Total chunks?"} + Chunks -->|"10 or fewer"| Full["Send raw HTML
to /api/init_multichat_full"] + Chunks -->|"More than 10"| Summary["Send pre-computed summaries
to /api/init_multichat_with_summaries"] + Single --> Stream1["Stream from /api/chat/stream"] + Full --> Stream2["Stream from /api/multichat_full/stream"] + Summary --> Stream3["Stream from /api/multichat/stream"] +
+
+ +

+ This three-way split exists because sending raw HTML for many chats would + overwhelm the LLM's context window. For small contexts, full HTML gives the + best answer quality. For large contexts, summaries compress the essential + information into a manageable size. +

+ +

4. Streaming the Answer

+

+ The backend streams tokens back via Server-Sent Events. Each token is + forwarded from the background thread to the Swing EDT (Event Dispatch Thread), + where it enters a timer-based animation queue. Every 30 milliseconds, one + token is dequeued and appended to the draft message, which is then + re-rendered as markdown. This creates a smooth "typewriter" effect. +

+ +

5. Rendering

+

+ The markdown renderer handles headings, bold, italic, blockquotes, lists, + and — importantly — citation tokens. Citations use the format + <<hash-chunkId>> and are rendered as blue + underlined links. Clicking one navigates IPED's viewer to the referenced + forensic item. +

+ +

6. Persisting the Conversation

+

+ Every message is saved to disk as a JSON file in + <case_dir>/iped/data/ai_chats/. Conversations can be + switched, resumed, or deleted (soft-delete preserves the file for + auditability). The coordinator caches backend session hashes so follow-up + questions within the same context skip re-initialization. +

+ +
+
+ + + + +
+

Development Journey

+
+

+ The feature was built incrementally over 157 commits. Here is a summary + of the major phases: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PhaseWhat Was BuiltKey Commits
FoundationBackend HTTP client, config, DTOs, WhatsApp HTML extraction, basic send flow wired to a mock backendfeat(llm integration): add backend http client
feat: add WhatsApp chat content extraction
Multi-turnConversational memory, previous messages sent with each request, markdown rendering with draft/commit/discard lifecyclefeat(llm): add multi turn conversational memory
feat(markdown): enhance message handling
Multi-chatMulti-chat DTOs, payload factory, three backend endpoints (single, full, summarized), chunk-based routingfeat(multi-chat-integration): implement multi-chat DTOs
feat: add multi-chat routing to coordinator
UI RefactorExtracted ChatAreaPanel, SidebarPanel, ContextPanel, HeaderPanel from monolithic assistant panel. Moved events through controller.refactor: Introduce ChatAreaPanel and HeaderPanel
Refactor: make the panel components purely visual
PersistenceConversation model, JSON persistence, sidebar with conversation list, auto-title generation, soft-deletefeat: define conversation data model
feat: implement conversation persistence layer
Context ManagementContext-edit lock, auto-fork flow, validation, right-click menu integration in MenuClassrefactor: implement context locking mechanism
refactor: Move AI context-add workflow from MenuClass to AIAssistantController
Summarization PipelinePython summarization task, config files, summary viewer in IPED's viewer panelAISummarizationTask.py (677 lines), SummaryViewer.java
PolishError recovery, partial draft salvage, thinking blocks, collapsible UI, quick actions, localizationfix: preserve partial streaming draft
feat: implement collapsible thinking blocks
+
+
+ + + + +
+

Key Design Decisions

+
+ +

Self-Contained AI Package

+

+ All chatbot logic lives under iped.app.ui.ai. Existing IPED + code was modified in only three places: the toolbar button + (App.java), the context menu (MenuClass.java), and + the viewer panel (ViewerController.java). This keeps the + feature isolated and removable without affecting the rest of the application. +

+ +

Controller-Mediator Pattern

+

+ The AIAssistantController acts as the single entry point for + all events. View components (panels) are purely visual — they don't + contain business logic. All user actions flow through the controller, which + delegates to the coordinator, context manager, or conversation manager as + needed. This made it possible to refactor individual panels without breaking + the overall flow. +

+ +

Three-Path Backend Routing

+

+ Rather than a single endpoint that tries to handle all data volumes, the + system uses three purpose-built backend paths. This keeps the backend simple + and deterministic: each endpoint knows exactly what format to expect and how + to process it. The trade-off is slightly more routing logic on the client + side, but the clarity is worth it. +

+ +

Context-Edit Lock + Auto-Fork

+

+ Once the LLM has processed a context, it's locked in the UI. Modifying it + afterward would create an inconsistent state (the backend's cache reflects the + old context, but the UI shows new files). Instead, the system offers an + "auto-fork": a new conversation is created that inherits the old context and + merges the new files. The previous conversation remains as a historical + record. +

+ +

Soft Delete

+

+ Conversations are never truly deleted from disk. They're marked with + status = "deleted" and hidden from the UI, but the JSON file + remains for forensic auditability. +

+ +

Progressive Enhancement

+

+ The feature was built in layers: first a mock backend, then real HTTP, then + streaming, then multi-chat, then persistence, then polish. Each phase was + functional on its own, which allowed continuous testing and integration. +

+ +
+
+ + + + +
+

What's in the Chatbot vs. What's Not

+
+

+ This report covers the chatbot feature only. The agent + feature (opencode CLI integration, MCP server, JVM bridge) is a separate + capability that shares some infrastructure but is not part of this report. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
In Scope (Chatbot)Out of Scope (Agent)
WhatsApp chat context managementOpenCodeAgentService
Three-path backend routing (single / full / summarized)MCP server (Python, stdio transport)
SSE streaming with teletype animationJVM bridge (PyJnius)
Markdown rendering with citation tokensopencode CLI subprocess
Context-edit lock and auto-forkAgent session persistence
Conversation persistence and managementAgent citation tools (get_citation_token, etc.)
Error recovery and partial draft salvageAgent error recovery flow
+ +

+ Both features share the AIAssistantController, + AIChatCoordinator, ChatAreaPanel, + AIMarkdownRenderer, ChatStreamAnimator, + ConversationManager, ConversationPersistence, and + the conversation model classes. The controller routes between them based on + isAgentConversation(). +

+
+
+ + + + +
+

File Impact Summary

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CategoryCountDetails
New Java files~40All under iped.app.ui.ai.* subpackages
Modified Java files6App.java, MenuClass.java, ViewerController.java, UICaseDataLoader.java, UiUtil.java, ExtraProperties.java
Moved Java files5AI filter classes moved from ai/ to ai/filters/
New config/resource files5AISummarizationConfig.txt, AIFiltersConfig.json, AISummarizationTask.py, updates to IPEDConfig.txt and TaskInstaller.xml
Localization files63 keys added to each of 6 languages (en, de, es, fr, it, pt)
Documentation (HTML)4+Technical reference (chatbot), 2 Sequence diagrams flow files, this report
+ +
+ Impact on existing IPED code is minimal. The 6 modified Java + files received small, targeted changes: a toolbar button, two menu items, a + viewer registration, an import path update, a utility method overload, and + two constant definitions. No existing behavior was altered. +
+ +
+
+ + + + +
+

Conclusion

+
+

+ The chatbot feature is a substantial addition to IPED, adding conversational + AI capabilities for forensic WhatsApp chat analysis. Despite its scope + (~5,800 lines of new Java code), it was designed to be minimally invasive: + nearly all changes live inside the new ai package, and the six + modified files received only small, surgical additions. +

+

+ The architecture prioritizes clarity (three purpose-built backend paths over + one generic endpoint), safety (context-edit lock prevents inconsistent state), + and resilience (partial draft salvage, hash cache invalidation on backend + restarts). The progressive build-up from mock backend to full feature was + enabled by a clean separation between UI, controller, coordinator, and + network layers. +

+

+ For detailed sequence diagrams covering all 12 flows, see the companion + document: + Chatbot sequence diagrams.html. For the full technical + reference, see + Chatbot Complete Technical Documentation.html. +

+
+
+ + + From 0d9a17ba309dc4a87a87275db3a29e92547bc20c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Tue, 14 Jul 2026 16:46:21 -0300 Subject: [PATCH 135/143] refactor: update IPED_HOME and CASE_PATH handling in Settings class to use relative paths --- .../scripts/mcp/iped-mcp-server/.env | 6 ----- .../scripts/mcp/iped-mcp-server/src/config.py | 22 +++++-------------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/.env b/iped-app/resources/scripts/mcp/iped-mcp-server/.env index 1c2431b0f0..030998d357 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/.env +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/.env @@ -1,11 +1,5 @@ # IPED MCP Server Configuration -# Path to IPED installation folder (contains lib/, iped-engine*.jar, etc.) -IPED_HOME=C:\iped\IPED\target\release\iped-4.4.0-SNAPSHOT - -# Path to processed case folder (or .txt file listing multiple cases) -CASE_PATH=C:\iped\output\output002_859fc10df - # Optional: Custom JAVA_HOME (defaults to JAVA_HOME env var) JAVA_HOME=C:\Program Files\BellSoft\LibericaJDK-11-Full\ diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py index 279ddd8ea9..4fb235cd58 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py @@ -9,8 +9,8 @@ @dataclass class Settings: - iped_home: Path = field(default_factory=lambda: Path(os.getenv("IPED_HOME", "") or ".")) - case_path: Path = field(default_factory=lambda: Path(os.getenv("CASE_PATH", "") or ".")) + iped_home: Path = field(default_factory=lambda: Path(__file__).resolve().parents[4]) + case_path: Path = field(default_factory=lambda: Path(__file__).resolve().parents[5]) java_home: str = field(default_factory=lambda: os.getenv("JAVA_HOME", os.environ.get("JAVA_HOME", ""))) jvm_max_heap: str = field(default_factory=lambda: os.getenv("JVM_MAX_HEAP", "4g")) mcp_host: str = field(default_factory=lambda: os.getenv("MCP_HOST", "127.0.0.1")) @@ -19,25 +19,15 @@ class Settings: def validate(self) -> list[str]: errors = [] - iped_raw = os.getenv("IPED_HOME", "") - if not iped_raw: - errors.append( - "IPED_HOME is not set. Add 'IPED_HOME=' to .env or set the IPED_HOME environment variable." - ) - elif not self.iped_home.is_dir(): + if not self.iped_home.is_dir(): errors.append( f"IPED_HOME directory does not exist: {self.iped_home}. " - f"Check the path in .env or environment variable." + f"Check the relative path." ) - case_raw = os.getenv("CASE_PATH", "") - if not case_raw: - errors.append( - "CASE_PATH is not set. Add 'CASE_PATH=' to .env or set the CASE_PATH environment variable." - ) - elif not Path(case_raw).exists(): + if not self.case_path.exists(): errors.append( - f"CASE_PATH does not exist: {case_raw}. Check the path in .env or environment variable." + f"CASE_PATH does not exist: {self.case_path}. Check the relative path." ) java = self.java_home From 31d2aa8941f7008db97a781c060d72105eebb765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Tue, 14 Jul 2026 17:01:18 -0300 Subject: [PATCH 136/143] refactor: improve JAVA_HOME handling in config to use a dedicated function --- .../scripts/mcp/iped-mcp-server/.env | 2 +- .../scripts/mcp/iped-mcp-server/src/config.py | 25 +++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/.env b/iped-app/resources/scripts/mcp/iped-mcp-server/.env index 030998d357..94fe1c655e 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/.env +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/.env @@ -1,6 +1,6 @@ # IPED MCP Server Configuration -# Optional: Custom JAVA_HOME (defaults to JAVA_HOME env var) +# Optional: Custom JAVA_HOME JAVA_HOME=C:\Program Files\BellSoft\LibericaJDK-11-Full\ # JVM maximum heap size diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py index 4fb235cd58..ea521a36ba 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py @@ -1,5 +1,6 @@ import os import re +import shutil from pathlib import Path from dataclasses import dataclass, field from dotenv import load_dotenv @@ -7,11 +8,31 @@ load_dotenv() +def _find_java_home() -> str: + env_java = os.getenv("JAVA_HOME", os.environ.get("JAVA_HOME", "")) + if env_java: + return env_java + + iped_home = Path(__file__).resolve().parents[4] + + # 1. JRE Embutido no IPED + builtin_jre = iped_home / "jre" + if builtin_jre.is_dir(): + return str(builtin_jre) + + # 2. Variável PATH do Sistema + java_exe = shutil.which("java") + if java_exe: + return str(Path(java_exe).resolve().parents[1]) + + return "" + + @dataclass class Settings: iped_home: Path = field(default_factory=lambda: Path(__file__).resolve().parents[4]) case_path: Path = field(default_factory=lambda: Path(__file__).resolve().parents[5]) - java_home: str = field(default_factory=lambda: os.getenv("JAVA_HOME", os.environ.get("JAVA_HOME", ""))) + java_home: str = field(default_factory=_find_java_home) jvm_max_heap: str = field(default_factory=lambda: os.getenv("JVM_MAX_HEAP", "4g")) mcp_host: str = field(default_factory=lambda: os.getenv("MCP_HOST", "127.0.0.1")) mcp_port: int = field(default_factory=lambda: int(os.getenv("MCP_PORT", "8100"))) @@ -33,7 +54,7 @@ def validate(self) -> list[str]: java = self.java_home if not java: errors.append( - "JAVA_HOME is not set. Add 'JAVA_HOME=' to .env or set the JAVA_HOME environment variable." + "JAVA_HOME could not be found automatically. Please ensure Java is in your PATH or that the IPED folder contains a 'jre' folder." ) else: java_exe = Path(java) / "bin" / "java.exe" From b392a5034f2b75237db278f4225cefb7aea185b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Tue, 14 Jul 2026 17:39:19 -0300 Subject: [PATCH 137/143] refactor: redirect stdout to stderr for improved logging in FastMCP --- .../scripts/mcp/iped-mcp-server/src/main.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py index be072a39f1..e68d8dc3aa 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py @@ -1,5 +1,19 @@ import logging import sys +import os + +# --- NOVO BLOCO DE REDIRECIONAMENTO --- +# Salva o file descriptor original do stdout para o MCP usar +original_stdout_fd = os.dup(1) + +# Redireciona o stdout nativo (FD 1) para o stderr (FD 2) +# Qualquer biblioteca C/C++ ou Java escreverá no stderr agora +os.dup2(2, 1) + +# Restaura o sys.stdout do Python para apontar para o FD original salvo, +# garantindo que o FastMCP consiga enviar o JSON-RPC limpo. +sys.stdout = os.fdopen(original_stdout_fd, 'w', buffering=1) +# -------------------------------------- from fastmcp import FastMCP From c89600867a71d456255cb3dfb20e5e5626f393aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Tue, 14 Jul 2026 18:10:43 -0300 Subject: [PATCH 138/143] fix: adjust popup menu size to match new chat button width for better UI alignment --- iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java | 1 + 1 file changed, 1 insertion(+) diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java index ddbb1ac215..42cb194ba5 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java @@ -80,6 +80,7 @@ private void initComponents() { JMenuItem newAgentItem = new JMenuItem("New Agent Chat"); newAgentItem.addActionListener(ev -> listener.onNewAgentChatRequested()); menu.add(newAgentItem); + menu.setPreferredSize(new Dimension(newChatButton.getWidth(), menu.getPreferredSize().height)); menu.show(newChatButton, 0, newChatButton.getHeight()); }); add(newChatButton, BorderLayout.NORTH); From 9e3c4548e6ee10c3eae78d5e10b80ef84ff9c41c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Tue, 14 Jul 2026 18:11:21 -0300 Subject: [PATCH 139/143] refactor: no quick action panel in agent mode --- .../iped/app/ui/ai/controller/AIAssistantController.java | 3 +++ .../src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java index 3546aefae8..af3fb4460e 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -231,6 +231,7 @@ private boolean ensureChatServiceInitialized() { private void startNewChat() { conversationManager.startNewConversation(); mainView.setContextPanelVisible(true); + mainView.setTasksPanelVisible(true); clearChatScreenAndMemory(); contextManager.clearContext(); sidebarView.updateConversationsList(conversationManager.getConversations()); @@ -243,6 +244,7 @@ private void startNewChat() { private void startNewAgentChat() { conversationManager.startNewConversation(true); mainView.setContextPanelVisible(false); + mainView.setTasksPanelVisible(false); clearChatScreenAndMemory(); contextManager.clearContext(); sidebarView.updateConversationsList(conversationManager.getConversations()); @@ -279,6 +281,7 @@ private void loadConversation(Conversation conv) { isSwitchingChats = true; conversationManager.setActiveConversation(conv); mainView.setContextPanelVisible(!conv.isAgentConversation()); + mainView.setTasksPanelVisible(!conv.isAgentConversation()); if (coordinator != null) { if (conv instanceof StandardConversation) { diff --git a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java index c8c44d5fcc..54b2658fab 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -321,6 +321,12 @@ public void setContextPanelVisible(boolean visible) { } } + public void setTasksPanelVisible(boolean visible) { + if (tasksPanel != null) { + tasksPanel.setVisible(visible); + } + } + /** * Exposes the context addition workflow to external UI components (e.g., IPED context menus). * Delegates all state evaluation and routing logic to the {@link AIAssistantController}. From 67b410dbf8f55ef876a54bb62f25f0497dd7f59a Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Wed, 15 Jul 2026 14:06:32 -0300 Subject: [PATCH 140/143] doc(agent): adds readme to agent mcp folder --- .../scripts/mcp/iped-mcp-server/README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/README.md b/iped-app/resources/scripts/mcp/iped-mcp-server/README.md index fd8199a725..275b2cf60a 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/README.md +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/README.md @@ -19,22 +19,21 @@ pip install -r requirements.txt ## Configuração -Edite o arquivo `.env`: +Edite o arquivo `.env` (somente as variáveis opcionais): ```env -# Caminho da instalação do IPED (pasta que contém lib/, iped-engine.jar, etc.) -IPED_HOME=C:\IPED\iped-4.3.1 - -# Caminho do caso processado (pasta do caso ou .txt listando múltiplos casos) -CASE_PATH=C:\Cases\meu-caso-processado - # Opcional: caminho do JDK (se diferente do JAVA_HOME do sistema) +# Se não definido, o servidor procura automaticamente: variável de ambiente → pasta "jre" no IPED → PATH do sistema JAVA_HOME=C:\Program Files\Java\jdk-11 # Memória máxima para a JVM JVM_MAX_HEAP=4g ``` +> **Nota:** `IPED_HOME` e `CASE_PATH` são resolvidos automaticamente a partir da +> localização do arquivo `config.py` (caminhos relativos). Não é necessário +> configurá-los no `.env`. + ## Uso ### Iniciar servidor (modo stdio — padrão para OpenCode) @@ -59,10 +58,12 @@ python -m src.main sse | `search(query, source_id?)` | Busca com sintaxe Lucene | | `search_by_type(file_type, source_id?)` | Busca por extensão (pdf, jpg, etc.) | | `search_by_name(pattern, source_id?)` | Busca por nome com wildcards | +| `get_searchable_fields()` | Retorna nomes e tipos dos campos indexáveis | | `get_document(source_id, doc_id)` | Metadados do documento | | `get_document_content(source_id, doc_id)` | Conteúdo binário (base64) | | `get_document_text(source_id, doc_id)` | Texto extraído pelo parser | -| `get_document_thumbnail(source_id, doc_id)` | Thumbnail (base64 JPEG) | +| `read(source_id, doc_id)` | Metadados + texto completos de um documento | +| `read_batch(source_id, doc_ids)` | Metadados + texto de múltiplos documentos | | `list_bookmarks()` | Lista nomes dos bookmarks | | `get_bookmark(name)` | Documentos em um bookmark | From 58c5233abb77917268e98b66406ab28c525ae0b8 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Wed, 15 Jul 2026 14:07:10 -0300 Subject: [PATCH 141/143] fix (documentation): update documentation to better reflect the lastest commit additions --- .../agent/Agent sequence diagrams.html | 148 ++++-------------- ...tbot Complete Technical Documentation.html | 15 +- 2 files changed, 40 insertions(+), 123 deletions(-) diff --git a/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html b/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html index 46489e119b..b966d1bc65 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html +++ b/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html @@ -111,10 +111,9 @@

Agent Sequence Diagrams

3. Follow-Up Question (With Session)4. MCP Server Initialization5. MCP Tool Execution • - 6. Agent Citation Generation • - 7. Agent Error Recovery • - 8. Switch Agent Conversation • - 9. Delete Agent Conversation + 6. Agent Error Recovery • + 7. Switch Agent Conversation • + 8. Delete Agent Conversation @@ -127,7 +126,8 @@

Architecture Overview

View Layer

AIAssistantPanel: top-level JFrame container. Controls - context-panel visibility (hidden for agent conversations). + context-panel and quick-actions panel visibility (both hidden for + agent conversations).

SidebarPanel: provides "New Agent Chat" dropdown @@ -135,16 +135,11 @@

View Layer

ChatAreaPanel: renders agent responses with - streaming, handles citation token clicks. + streaming.

AIMarkdownRenderer: parses agent output markdown - including <<hash-chunkId>> citation tokens. -

-

- ⚠ Citation rendering is in early stages. The token format is defined - but end-to-end navigation (MCP → renderer → item viewer) has known - issues. See Flow 6. + including <<hash-chunkId>> tokens.

@@ -213,16 +208,8 @@

MCP Server (Python, stdio transport)

get_searchable_fields, get_document, get_document_content, get_document_text, read, read_batch, - list_bookmarks, get_bookmark, - list_sources, get_citation_token, - get_citation_info, get_citation_tokens_batch. -

-

- ⚠ Citation tools (get_citation_token, - get_citation_info, - get_citation_tokens_batch) are implemented but the - hash semantics are not yet aligned with the Java-side navigation - handler. See Flow 6. + list_bookmarks, get_bookmark, + list_sources.

@@ -274,6 +261,7 @@

Flow 1: Start Agent Chat

Controller->>ConvMgr: startNewConversation(true) Note over ConvMgr: Creates AgentConversation (isAgentConversation=true) Controller->>MainPanel: setContextPanelVisible(false) +Controller->>MainPanel: setTasksPanelVisible(false) Controller->>Controller: clearChatScreenAndMemory() Controller->>CtxMgr: clearContext() Controller->>Sidebar: updateConversationsList(conversations) @@ -443,8 +431,11 @@

Flow 4: MCP Server Initialization

When opencode starts, it reads opencode.json and launches the MCP server as a subprocess (python -m src.main). - The MCP server validates configuration, initializes the JVM with IPED - classpath, opens the forensic case, and registers all tools. + The MCP server resolves paths automatically from its own file location + (IPED_HOME and CASE_PATH are derived relative to + config.py), auto-detects JAVA_HOME via a fallback + chain, validates configuration, initializes the JVM with IPED classpath, + opens the forensic case, and registers all tools.

@@ -463,8 +454,13 @@

Flow 4: MCP Server Initialization

Config-->>OC: model = "local/Qwen3.5-122B" OC->>MCP_Main: spawn subprocess (stdio transport) +Note over MCP_Main: Redirects native stdout to stderr so Java/PyJnius
don't corrupt MCP JSON-RPC over stdio + MCP_Main->>Settings: settings.validate() -Settings->>Settings: check IPED_HOME, CASE_PATH, JAVA_HOME, JVM_MAX_HEAP +Settings->>Settings: Resolve IPED_HOME from config.py relative path (parents[4]) +Settings->>Settings: Resolve CASE_PATH from config.py relative path (parents[5]) +Settings->>Settings: Auto-detect JAVA_HOME: env → IPED/jre → system PATH +Settings->>Settings: Check JVM_MAX_HEAP (env or default "4g") Settings-->>MCP_Main: errors (if any) or OK alt validation failed @@ -490,11 +486,10 @@

Flow 4: MCP Server Initialization

end MCP_Main->>Tools: register_tools() -Note over Tools: 15 tools registered: search, search_by_type, search_by_name, +Note over Tools: 12 tools registered: search, search_by_type, search_by_name, Note over Tools: get_searchable_fields, get_document, get_document_content, Note over Tools: get_document_text, read, read_batch, list_bookmarks, -Note over Tools: get_bookmark, list_sources, get_citation_token, -Note over Tools: get_citation_info, get_citation_tokens_batch +Note over Tools: get_bookmark, list_sources MCP_Main->>MCP_Main: mcp.run(transport="stdio") Note over MCP_Main: Ready to receive tool calls from opencode @@ -557,99 +552,17 @@

Flow 5: MCP Tool Execution

MCP-->>OC: {hash_info, metadata, content} OC->>LLM: tool_result: document metadata + text -LLM->>OC: text response with citation tokens +LLM->>OC: text response with [[hash-chunkId]] tokens OC-->>AgentSvc: JSON-Lines stdout with text content
- - -
-

Flow 6: Agent Citation Generation

-
- ⚠ Work in Progress
- Citation features are in their early stages. The MCP tools, renderer - token parsing, and click handler are all implemented, but the - end-to-end flow is not yet fully working. The hash value used in - citation tokens (Lucene internal ID) does not yet match what the - Java navigation handler expects (content hash). This diagram - documents the intended architecture at this snapshot in time. -
-

- The agent generates citations using the <<hash-chunkId>> - format. This can happen in two ways: (1) the LLM constructs citations - directly from tool result hashes, or (2) the LLM calls the dedicated - get_citation_token / get_citation_info MCP - tools. The resulting tokens are rendered as clickable links by - AIMarkdownRenderer in the chat UI. -

-

- Note: the Mermaid diagram below uses [[...]] to represent - <<...>> tokens, which conflict with Mermaid's - << stereotype syntax. -

-
-
-sequenceDiagram -autonumber -participant LLM as LLM (Qwen3.5-122B) -participant OC as opencode CLI -participant MCP as MCP Server -participant CaseMgr as IPEDCaseManager -participant AgentSvc as OpenCodeAgentService -participant ChatArea as ChatAreaPanel -participant Renderer as AIMarkdownRenderer -participant Controller as AIAssistantController -participant IPED as IPED Searcher / Viewer -actor User - -Note over LLM: LLM decides to reference a document - -alt LLM constructs citation from tool result hash - LLM->>OC: 'text: "Found document [[12345-ch42]] in the case"' - OC-->>AgentSvc: 'text: "Found document [[12345-ch42]]..."' -else LLM calls citation tool - LLM->>OC: 'tool_use: get_citation_token(source_id=0, doc_id=42)' - OC->>MCP: 'JSON-RPC: tools/call' - MCP->>CaseMgr: 'get_item(42, 0)' - CaseMgr-->>MCP: '{lucene_id: 12345}' - MCP-->>OC: '[[12345-ch42]]' - OC->>LLM: 'tool_result: [[12345-ch42]]' - LLM->>OC: 'text: "The file [[12345-ch42]] contains..."' - OC-->>AgentSvc: 'text: "The file [[12345-ch42]]..."' -end - -AgentSvc->>AgentSvc: extractTextFromJsonLines() -AgentSvc-->>ChatArea: uiCallback(text with citation tokens) -ChatArea->>ChatArea: enqueueStreamingToken(token) - -Note over Renderer: Timer tick renders markdown -ChatArea->>Renderer: renderDraft(assistantDraft) -Renderer->>Renderer: Parse [[hash-chunkId]] tokens -Renderer->>Renderer: appendToken with attributes - -Note over ChatArea: User sees clickable citation link - -User->>ChatArea: click citation token [[12345-ch42]] -ChatArea->>ChatArea: Read TOKEN_HASH_ATTRIBUTE=12345 -ChatArea->>ChatArea: Read TOKEN_CHUNK_ID_ATTRIBUTE=42 -ChatArea->>Controller: navigationCallback("12345", "42") -Controller->>IPED: search by hash ("hash:12345") -IPED-->>Controller: luceneId -Controller->>IPED: setElementIDToScroll("42") -Controller->>IPED: new FileProcessor(luceneId, true).execute() -Controller->>IPED: selectItemInResultsTable(luceneId) -
-
-
- - - +
-

Flow 7: Agent Error Recovery

+

Flow 6: Agent Error Recovery

Agent errors can occur at three points: opencode process fails to start, the process exits with a non-zero code, or an exception occurs during @@ -719,10 +632,10 @@

Flow 7: Agent Error Recovery

- +
-

Flow 8: Switch Agent Conversation

+

Flow 7: Switch Agent Conversation

Switching to a previous agent conversation restores the chat messages and the sessionId. No background context restoration occurs @@ -746,6 +659,7 @@

Flow 8: Switch Agent Conversation

Controller->>Controller: isSwitchingChats = true Controller->>ConvMgr: setActiveConversation(conv) Controller->>MainPanel: setContextPanelVisible(false) +Controller->>MainPanel: setTasksPanelVisible(false) Controller->>Coord: loadHistoricalContext(null, null, conv.messages) Note over Coord: Restores chatHistory (user + assistant turns only) @@ -763,10 +677,10 @@

Flow 8: Switch Agent Conversation

- +
-

Flow 9: Delete Agent Conversation

+

Flow 8: Delete Agent Conversation

Deletion follows the same soft-delete pattern as standard conversations. The file is saved with status = "deleted" and preserved on diff --git a/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot Complete Technical Documentation.html b/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot Complete Technical Documentation.html index 2d0c4f5ce0..68970eab63 100644 --- a/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot Complete Technical Documentation.html +++ b/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot Complete Technical Documentation.html @@ -482,7 +482,10 @@

5.2 Validation

  • The item must be a WhatsApp chat (verified via AIWhatsappChatExtractor.isWhatsAppChatType()).
  • The item must not belong to the "Empty Files" category.
  • -
  • The item must not have its communication flagged as empty.
  • +
  • If the item already has a pre-computed AI summary (detected via + SummaryValueExtractor.hasSummary()), it is accepted immediately + and the remaining checks are skipped.
  • +
  • Otherwise, the item must not have its communication flagged as empty.
  • Valid items are stored as ContextFileEntry objects in the @@ -578,7 +581,7 @@

    6.2 Key Details

    reads the IPED item's InputStream into a UTF-8 string. It verifies the item is a WhatsApp chat type before reading.
  • Initialization: The HTML is wrapped in an - AIInitChatRequest ({"chat_html": "..."}) and sent + AIInitChatRequest ({"chat_content": "..."}) and sent to /api/init_chat. The backend returns a hash that identifies the cached session.
  • Session caching: The hash is stored in @@ -869,7 +872,7 @@

    10.1 SSE Event Parsing

    thinkingWrapped in [[AI_THINKING]]...[[/AI_THINKING]] markers for collapsible rendering. thinking_doneCloses the thinking block. - statusFormatted as italic markdown: _[Status]: content_ + statusFormatted as bold inside italic markdown: _**[Status]:** content_ finalFirst token gets **[FINAL ANSWER]:** prefix; subsequent tokens pass through. errorThrows AIBackendException. [DONE] or emptyIgnored (keep-alive / termination signal). @@ -985,9 +988,9 @@

    11.3 Citation Click Handling

    Design note: Citations work for both chatbot and agent features. The chatbot's backend produces citations from the HTML chat - content during summarization. The agent's MCP tools can produce citations - from search and document-read results (though this path is still in early - stages). + content during summarization. The agent's MCP citation tools are planned + but not yet implemented; the LLM may produce citation tokens + directly in its text output using tool result hashes.
  • From 9a19771f5176445ce4653966b8cfc7e1d2373149 Mon Sep 17 00:00:00 2001 From: Felipe Gibin Date: Wed, 15 Jul 2026 14:38:06 -0300 Subject: [PATCH 142/143] doc(agent): adds comprehensive technical documentation on the agent feature --- ...gent Complete Technical Documentation.html | 949 ++++++++++++++++++ 1 file changed, 949 insertions(+) create mode 100644 iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent Complete Technical Documentation.html diff --git a/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent Complete Technical Documentation.html b/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent Complete Technical Documentation.html new file mode 100644 index 0000000000..58a7122f8e --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent Complete Technical Documentation.html @@ -0,0 +1,949 @@ + + + + + + Agent Complete Technical Documentation + + + + + + +

    Agent Complete Technical Documentation

    +

    Internal developer documentation — IPED AI Agent Module — July 2026

    + + + + +

    1. Overview

    + +
    +

    The Agent is an AI assistant integrated into IPED that communicates with an external opencode CLI process via JSON-Lines over stdin/stdout. It has autonomous access to the IPED case through a MCP (Model Context Protocol) server that exposes case data as callable tools.

    + +

    The agent differs fundamentally from the chatbot in that:

    +
      +
    • The AI model runs outside IPED (external process), not as an embedded model
    • +
    • The model can autonomously decide to call IPED tools (search, bookmark, document access) without user prompting
    • +
    • Conversations are stateless — no local persistence, no history summary
    • +
    • Each new chat session spawns a fresh opencode process
    • +
    + +
    +Key concept: The agent is not a direct API integration. It shells out to opencode.exe, which manages its own LLM provider connections, context, and conversation state. IPED acts as the orchestrator, launching the process and parsing its JSON-Lines output. +
    +
    + + +

    2. Architecture

    + +
    +

    The agent module is organized in five layers. The Java UI layer routes +user input to OpenCodeAgentService, which spawns and +communicates with the external opencode process. opencode +delegates IPED-specific work to the MCP server, which calls back into +the Java API through PyJnius.

    + +
    +
    +graph TB + subgraph Java["Java / IPED"] + subgraph UI["UI Layer"] + SP[SidebarPanel] + AC[AIAssistantController] + CC[AIChatCoordinator] + end + subgraph Agent["Agent Service"] + AS[OpenCodeAgentService] + ACV[AgentConversation] + end + subgraph JavaAPI["IPED Java API"] + SR[SearchRequest] + IS[IPEDSearcher] + IC[IPEDCase] + end + end + + subgraph External["External Process"] + OC[opencode CLI] + subgraph MCP["MCP Server (Python)"] + MC[case_manager.py] + JB[jvm_bridge.py] + TL[tools/] + end + end + + AC --> CC + CC --> AS + AS -->|stdin JSON| OC + OC -->|stdout JSON-Lines| AS + AS --> ACV + OC -->|MCP stdio| TL + TL --> MC + MC --> JB + JB -->|PyJnius| SR + JB -->|PyJnius| IS + JB -->|PyJnius| IC +
    +
    + +

    Request flow (simplified)

    +
    +
    +sequenceDiagram + participant User + participant AC as AIAssistantController + participant AS as OpenCodeAgentService + participant OC as opencode CLI + participant MC as MCP Server + participant IPED as IPED Java API + + User->>AC: askQuestion(text) + AC->>AS: askAgentQuestion(text, convId, cb) + AS->>OC: stdin ← JSON command + OC->>MC: MCP tool call (e.g. search) + MC->>IPED: SearchRequest + IPEDSearcher + IPED-->>MC: results + MC-->>OC: tool result (JSON) + OC-->>AS: stdout → JSON-Lines + AS-->>AC: callback.setAnswer(text) + AC-->>User: display response +
    +
    +
    + + +

    3. Component Inventory

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    LayerComponentFileResponsibility
    ViewSidebarPanelSidebarPanel.java"New Agent Chat" dropdown, "(Agent)" label
    AIChatPanelAIChatPanel.javaShared chat panel (same as chatbot)
    ControllerAIAssistantControllerAIAssistantController.javaRoutes to agent via isAgentConversation()
    AIChatCoordinatorAIChatCoordinator.javaDispatches via askAgentQuestion()
    Agent ServiceOpenCodeAgentServiceOpenCodeAgentService.javaProcess lifecycle, JSON-Lines parsing, stdin/stdout
    ModelAgentConversationAgentConversation.javaExtends Conversation with sessionId
    ConversationManagerConversationManager.javastartNewConversation(true) creates agent conv.
    ConversationPersistenceConversationPersistence.javaagent_ prefix, persists sessionId
    Externalopencode CLIopencode.exeLLM provider, MCP tool execution
    MCP Servermain.pysrc/main.pyEntry point, stdio transport, stdout redirect
    config.pysrc/config.pySettings, path resolution
    jvm_bridge.pysrc/jvm_bridge.pyPyJnius JVM initialization
    case_manager.pysrc/case_manager.pyIPEDCaseManager (search, items)
    + +
    + + +

    4. Java Integration Layer

    + +
    +

    4.1 OpenCodeAgentService.java

    +

    The core service class that manages the opencode process lifecycle and handles all communication.

    + +
    +Location: iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java +
    + +

    Key Fields

    + + + + + + + +
    FieldTypePurpose
    processProcessHandle to the running opencode process
    processWriterBufferedWriterWrites commands to opencode's stdin
    readerThreadThreadBackground thread reading stdout JSON-Lines
    chatHistoriesMap<Integer, List<Message>>Local chat history per conversation (in-memory only)
    activeCallbacksMap<Integer, MessageCallback>Tracks active callbacks for streaming responses
    + +

    Key Methods

    + + + + + + + + + +
    MethodPurpose
    askAgentQuestion(text, convId, callback)Main entry point. Spawns process if needed, sends message, starts reader
    spawn()Launches opencode.exe as a subprocess with configured arguments
    sendCommand(command)Writes a JSON command to the process stdin
    readResponse(convId)Reads JSON-Lines from stdout until a complete response is assembled
    parseJsonLine(line)Parses a single JSON-Lines entry, extracting text/tool usage
    extractSessionId(line)Extracts the session ID from opencode's initial response
    stopAgent()Sends termination command and destroys the process
    + +

    4.2 AgentConversation.java

    +
    +Location: iped-app/src/main/java/iped/app/ui/ai/model/AgentConversation.java +
    +

    Extends Conversation with a single additional field:

    +
    +public class AgentConversation extends Conversation {
    +    private String sessionId;  // opencode session ID for resuming conversations
    +    // getter/setter
    +}
    +
    +

    The sessionId is extracted from the opencode process output and used to maintain conversation context across multiple questions within the same chat session.

    + +

    4.3 Routing Logic

    +

    In AIAssistantController.java, the method isAgentConversation() checks if the current conversation is an AgentConversation instance. When true:

    +
      +
    • AIChatCoordinator.askAgentQuestion() is called instead of the chatbot path
    • +
    • AgentConversationManager.getAgentConversations() returns only agent-type conversations
    • +
    • The sidebar shows "(Agent)" label on agent conversations
    • +
    +
    + + +

    5. opencode CLI

    + +
    +

    5.1 What is opencode?

    +

    opencode is a command-line AI coding assistant that:

    +
      +
    • Connects to LLM providers (configured via opencode.json)
    • +
    • Manages its own conversation context and tool execution
    • +
    • Communicates via JSON-Lines over stdin/stdout when run in non-interactive mode
    • +
    • Supports MCP tool registration for external capabilities
    • +
    + +

    5.2 How IPED Launches It

    +

    OpenCodeAgentService.spawn() executes:

    +
    +opencode.exe --non-interactive --cwd <case-dir>
    +
    +

    Key launch details:

    +
      +
    • The process is started with its working directory set to the current IPED case directory
    • +
    • stdin is configured for writing (PIPE), stdout for reading (PIPE)
    • +
    • The process inherits no console window (hidden process)
    • +
    • A reader thread is started immediately to process stdout
    • +
    + +

    5.3 opencode.json Configuration

    +
    +Location: iped-app/resources/scripts/mcp/iped-mcp-server/opencode.json +
    +

    This configuration file is placed alongside the MCP server and tells opencode:

    +
      +
    • Which LLM provider to use — e.g., OpenAI, Anthropic, or custom endpoints
    • +
    • Which MCP servers to register — the IPED MCP server with its tool definitions
    • +
    • Model settings — temperature, max tokens, etc.
    • +
    + +
    +Important: The opencode.json is distributed with IPED but the LLM API key must be provided by the user (typically via environment variable or opencode's own configuration). +
    +
    + + +

    6. MCP Server Architecture

    + +
    +

    6.1 Overview

    +

    The MCP server is a Python process that runs inside the opencode process space. It communicates with opencode via stdio (stdin/stdout JSON-RPC) and with IPED's Java API via PyJnius (Python-Java bridge).

    + +
    +
    +graph TB + subgraph opencode["opencode CLI"] + OC[opencode] + end + + subgraph MCP["MCP Server (Python)"] + direction TB + MC[main.py
    entry point + stdout redirect] + CF[config.py
    Settings] + JB[jvm_bridge.py
    PyJnius JVM init] + CM[case_manager.py
    IPEDCaseManager] + subgraph Tools["tools/"] + direction LR + T1[search.py] + T2[documents.py] + T3[bookmarks.py] + T4[sources.py] + end + end + + subgraph JavaAPI["IPED Java API"] + direction TB + SR[SearchRequest] + IS[IPEDSearcher] + IC[IPEDCase] + end + + OC <-->|stdio JSON-RPC| MC + MC --> CF + MC --> JB + MC --> CM + T1 --> CM + T2 --> CM + T3 --> CM + T4 --> CM + JB -->|PyJnius| SR + JB -->|PyJnius| IS + JB -->|PyJnius| IC +
    +
    + +

    6.2 main.py — Entry Point

    +
    +Location: iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py +
    +

    The entry point performs three critical tasks:

    +
      +
    1. Redirects stdout — Before any imports, sys.stdout is re-assigned to sys.stderr. This is essential because MCP uses stdin/stdout for JSON-RPC protocol, and any stray print statements would corrupt the protocol. All logging goes to stderr instead.
    2. +
    3. Initializes the JVM — Calls jvm_bridge.init_jvm() to start the Java Virtual Machine via PyJnius
    4. +
    5. Registers tools and starts the MCP server — Creates the FastMCP server instance, registers all tool functions, and runs it
    6. +
    + +
    +import sys
    +sys.stdout = sys.stderr  # Critical: redirect stdout for MCP protocol
    +
    +from config import Settings
    +from jvm_bridge import init_jvm
    +from case_manager import IPEDCaseManager
    +# ... tool imports ...
    +
    +settings = Settings()
    +init_jvm(settings)
    +
    +server = FastMCP("IPED MCP Server")
    +
    +@server.tool()
    +def search(query: str, ...): ...
    +
    +# ... register all tools ...
    +
    +if __name__ == "__main__":
    +    server.run()
    +
    + +

    6.3 config.py — Settings

    +
    +Location: iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py +
    +

    The Settings dataclass manages all configuration:

    + + + + + + + + +
    FieldDefaultPurpose
    iped_jar_pathauto-detectedPath to iped.jar
    iped_lib_dirauto-detectedPath to lib/ directory for classpath
    case_pathfrom env or CWDPath to the IPED case directory
    temp_dirtemp/iped-mcpTemporary files directory
    max_results100Default max search results
    jvm_args-Xmx2gJVM heap configuration
    +

    Path resolution follows a priority chain: environment variable → relative to script location → working directory fallback.

    +
    + + +

    7. MCP Configuration

    + +
    +

    7.1 opencode.json — MCP Server Registration

    +

    The opencode configuration registers the IPED MCP server:

    +
    +{
    +  "mcpServers": {
    +    "iped": {
    +      "command": "python",
    +      "args": ["src/main.py"],
    +      "cwd": "iped-app/resources/scripts/mcp/iped-mcp-server",
    +      "env": {
    +        "IPED_CASE_PATH": "${IPED_CASE_PATH}"
    +      }
    +    }
    +  }
    +}
    +
    + +
    +Environment variable passing: The IPED_CASE_PATH is injected by OpenCodeAgentService when spawning the process, so the MCP server knows which case to open. +
    + +

    7.2 Tool Declarations

    +

    Tools are declared via Python decorators in the MCP server. opencode reads these declarations at startup and makes the tools available to the LLM. The LLM then decides when to call each tool based on the user's request.

    +

    Available tools:

    + + + + + + +
    CategoryTools
    Searchsearch, search_by_type, search_by_name, get_searchable_fields
    Documentsget_document, get_document_content, get_document_text
    Bookmarkslist_bookmarks, get_bookmark
    Sourceslist_sources
    +
    + + +

    8. JVM Bridge (PyJnius)

    + +
    +

    8.1 How It Works

    +
    +Location: iped-app/resources/scripts/mcp/iped-mcp-server/src/jvm_bridge.py +
    +

    The JVM bridge uses PyJnius (a Python library for accessing Java classes from CPython) to call IPED's Java API directly from Python.

    + +

    Initialization Sequence

    +
      +
    1. Configure PyJnius classpath to include iped.jar and all JARs in lib/
    2. +
    3. Set JVM arguments (heap size, etc.)
    4. +
    5. Start the JVM via jnius.start_jvm()
    6. +
    7. Cache references to key Java classes: +
        +
      • SearchRequest
      • +
      • IPEDSearcher
      • +
      • ItemId
      • +
      • IPEDCase
      • +
      +
    8. +
    + +
    +from jnius import autoclass, cast
    +
    +def init_jvm(settings):
    +    classpath = f"{settings.iped_jar_path};{settings.iped_lib_dir}/*"
    +    jnius.start_jvm(
    +        classpath=classpath,
    +        jvmargs=settings.jvm_args.split()
    +    )
    +    # Cache commonly used classes
    +    global SearchRequest, IPEDSearcher, ItemId, IPEDCase
    +    SearchRequest = autoclass("iped.search.SearchRequest")
    +    IPEDSearcher = autoclass("iped.search.IPEDSearcher")
    +    ItemId = autoclass("iped.data.ItemId")
    +    IPEDCase = autoclass("iped.config.IPEDCase")
    +
    + +

    8.2 Limitations

    +
      +
    • The JVM is a singleton — once started, it cannot be restarted in the same process
    • +
    • Thread safety: Java calls from Python must be synchronized if accessing shared IPED state
    • +
    • Memory: JVM heap is shared with the MCP server process
    • +
    • Class loading: All IPED classes must be on the classpath at JVM init time
    • +
    +
    + + +

    9. Case Manager

    + +
    +

    9.1 IPEDCaseManager

    +
    +Location: iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py +
    +

    The IPEDCaseManager is a Python class that wraps the IPED Java API calls, providing a clean Python interface for the MCP tools.

    + +

    Core Capabilities

    + + + + + + + +
    MethodPurpose
    open_case(case_path)Opens an IPED case and initializes the case object
    search(query)Executes a Lucene query, returns list of matching items
    get_item_by_id(item_id)Retrieves a specific item by its ID
    get_item_children(item_id)Lists child items of a container
    get_item_text(item_id)Extracts text content from a document
    + +

    Search Implementation

    +
    +def search(self, query: str, max_results: int = 100):
    +    search_request = SearchRequest()
    +    search_request.setQuery(query)
    +    search_request.setMaxResults(max_results)
    +    
    +    searcher = IPEDSearcher(self.case, search_request)
    +    results = searcher.search()
    +    
    +    items = []
    +    for item_id in results.getIds():
    +        item = self.case.getItem(item_id)
    +        items.append({
    +            "id": str(item_id),
    +            "name": item.getName(),
    +            "type": item.getType(),
    +            "size": item.getLength(),
    +            "path": item.getPath()
    +        })
    +    return items
    +
    + +
    +Case must be open: All MCP tools require the case to be initialized. The first call to any tool will trigger open_case() if not already done. The case path is determined from the IPED_CASE_PATH environment variable. +
    +
    + + +

    10. MCP Tools Reference

    + +
    + +

    10.1 Search Tools

    + +
    +search(query, max_results=100)
    +Executes a Lucene query against the case. Returns a list of matching items with id, name, type, size, and path. +
    +# Example LLM tool call
    +search(query="*.pdf AND text:fraude")
    +# Returns: [{"id": "12345", "name": "doc.pdf", "type": "application/pdf", ...}]
    +
    +
    + +
    +search_by_type(mime_type, max_results=100)
    +Searches by MIME type. Useful for filtering to specific file categories. +
    +search_by_type(mime_type="image/jpeg")
    +search_by_type(mime_type="video/mp4")
    +
    +
    + +
    +search_by_name(name, max_results=100)
    +Searches by filename (supports wildcards). +
    +search_by_name(name="*.xlsx")
    +search_by_name(name="%relatorio%")
    +
    +
    + +
    +get_searchable_fields()
    +Returns a list of all available Lucene fields in the case index. Essential for the LLM to understand what can be queried. +
    +# Returns: ["name", "ext", "type", "size", "created", "modified", 
    +#           "accessed", "path", "content", "hash", ...]
    +
    +
    + +

    10.2 Document Tools

    + +
    +get_document(item_id)
    +Returns full metadata for a specific item. +
    +get_document(item_id="12345")
    +# Returns: {id, name, type, size, path, created, modified, ...}
    +
    +
    + +
    +get_document_content(item_id)
    +Returns the extracted text content of a document. +
    +get_document_content(item_id="12345")
    +# Returns: "Full text content of the document..."
    +
    +
    + +
    +get_document_text(item_id)
    +Similar to get_document_content but may return formatted/structured text. +
    + +

    10.3 Bookmark Tools

    + +
    +list_bookmarks()
    +Returns all bookmark categories in the case. +
    +# Returns: [{"name": "Relevant", "count": 15}, {"name": "Suspicious", "count": 8}]
    +
    +
    + +
    +get_bookmark(category)
    +Returns all items in a specific bookmark category. +
    +get_bookmark(category="Relevant")
    +# Returns: [{id, name, type, ...}, ...]
    +
    +
    + +

    10.4 Source Tools

    + +
    +list_sources()
    +Returns all evidence sources in the case. +
    +# Returns: [{"name": "disk.E01", "type": "disk", "path": "/evidence/disk.E01"}]
    +
    +
    + +
    + + +

    11. Request Lifecycle

    + +
    +

    11.1 First Question (no session)

    +
    +
    +sequenceDiagram + participant U as User + participant AC as AIAssistantController + participant AS as OpenCodeAgentService + participant OC as opencode CLI + participant MC as MCP Server + participant IPED as IPED Java API + + U->>AC: askQuestion(text) + AC->>AS: askAgentQuestion(text, convId, cb) + Note over AS: process == null → spawn() + AS->>OC: stdin ← {message, no sessionId} + OC->>MC: MCP: init_jvm() + tool call + MC->>IPED: SearchRequest + IPEDSearcher + IPED-->>MC: results + MC-->>OC: tool result JSON + OC-->>AS: stdout → text + sessionId + Note over AS: extractSessionId() → persist + AS-->>AC: callback.setAnswer(text) + AC-->>U: display response +
    +
    + +

    11.2 Follow-up Question (session exists)

    +
    +
    +sequenceDiagram + participant U as User + participant AC as AIAssistantController + participant AS as OpenCodeAgentService + participant OC as opencode CLI + participant MC as MCP Server + participant IPED as IPED Java API + + U->>AC: askQuestion(text) + AC->>AS: askAgentQuestion(text, convId, cb) + Note over AS: process running, sessionId loaded + AS->>OC: stdin ← {message, sessionId} + OC->>MC: MCP: tool call (if needed) + MC->>IPED: SearchRequest + IPED-->>MC: results + MC-->>OC: tool result JSON + OC-->>AS: stdout → JSON-Lines + AS-->>AC: callback.setAnswer(text) + AC-->>U: display response +
    +
    + +

    11.3 Command Format

    +

    Messages sent to opencode's stdin follow this JSON structure:

    +
    +{
    +  "command": "chat",
    +  "message": "What emails mention 'confidential'?",
    +  "sessionId": "abc123-def456"       // null on first question
    +}
    +
    + +

    11.4 Response Format

    +

    opencode outputs JSON-Lines on stdout. Each line is a separate JSON object:

    +
    +{"type":"text","content":"I found 5 emails mentioning "}
    +{"type":"text","content":"'confidential'. Here are the results:"}
    +{"type":"tool_use","name":"search","args":{"query":"confidential"}}
    +{"type":"tool_result","content":"[5 results found]"}
    +{"type":"text","content":"The most relevant email is..."}
    +
    +
    + + +

    12. Session Management

    + +
    +

    12.1 Session ID Lifecycle

    +
      +
    1. First question: OpenCodeAgentService sends the question without a session ID. opencode creates a new session and returns the session ID in its initial response.
    2. +
    3. Session ID extraction: extractSessionId() parses the opencode output to find the session ID. It is stored in AgentConversation.sessionId.
    4. +
    5. Subsequent questions: The session ID is included in each command, allowing opencode to maintain conversation context.
    6. +
    7. Persistence: Session ID is persisted with the conversation via ConversationPersistence (saved in the agent_ prefixed properties file).
    8. +
    + +

    12.2 Local Chat History

    +

    Despite conversations being stateless on the opencode side, OpenCodeAgentService maintains a local chat history in-memory:

    +
    +Map<Integer, List<Message>> chatHistories = new ConcurrentHashMap<>();
    +
    +

    This allows the UI to display the full conversation history even after opencode's session state is lost.

    + +

    12.3 Process Lifecycle

    + + + + + + + +
    EventAction
    User asks first questionSpawn opencode process
    User asks follow-upReuse existing process (if running)
    User clicks "New Agent Chat"Stop old process, spawn new one
    User switches between conversationsStop current process, spawn new one for target conversation
    IPED shutdownDestroy all opencode processes
    +
    + + +

    13. JSON-Lines Parsing

    + +
    +

    13.1 parseJsonLine()

    +

    The parseJsonLine(line) method processes each line of output from opencode:

    +
      +
    1. Trim whitespace and skip empty lines
    2. +
    3. Parse JSON using a JSON library (e.g., Jackson or Gson)
    4. +
    5. Check the type field to determine the message type: +
        +
      • "text" — Extract content field, append to response buffer
      • +
      • "tool_use" — Log the tool call (tool name + args), optionally display in UI
      • +
      • "tool_result" — Log the tool result, may append summary to response
      • +
      • "session_id" — Extract and store the session ID
      • +
      • "error" — Handle error messages
      • +
      +
    6. +
    + +

    13.2 Response Assembly

    +

    Text fragments are accumulated in a StringBuilder until the process signals completion (EOF or a terminal marker). The assembled text is then passed to the callback as the final answer.

    + +
    +Streaming support: The callback can receive partial text updates as they arrive, enabling real-time display of the agent's response (similar to chatbot streaming). +
    +
    + + +

    14. Error Recovery

    + +
    +

    14.1 Process Crash Recovery

    +

    If the opencode process crashes or exits unexpectedly:

    +
      +
    1. The reader thread detects EOF on stdout
    2. +
    3. activeCallbacks is checked — any pending callback receives an error message
    4. +
    5. The process reference is set to null
    6. +
    7. On the next question, spawn() is called again automatically
    8. +
    + +

    14.2 MCP Server Failure

    +

    If the MCP server (Python process) fails to start or crashes:

    +
      +
    • opencode will report the MCP server as unavailable
    • +
    • The LLM will respond without tool access (general knowledge only)
    • +
    • IPED logs the error but does not crash
    • +
    + +

    14.3 JVM Initialization Failure

    +
    +Critical error: If PyJnius cannot start the JVM (e.g., IPED JARs not found, incompatible Java version), the MCP server will fail to initialize. The error is logged to stderr, and opencode reports the tools as unavailable. +
    + +

    14.4 Communication Errors

    + + + + + + +
    Error TypeDetectionRecovery
    Invalid JSONJSON parse exception in parseJsonLine()Log warning, skip line, continue reading
    Stdin write failureIOException when writing to processKill process, spawn new one on next request
    Stdin closed (process exited)Broken pipe errorSame as crash recovery
    TimeoutNo output for extended periodConfigurable timeout, kill and restart
    +
    + + +

    15. Agent vs Chatbot Comparison

    + +
    + + + + + + + + + + + + +
    AspectChatbotAgent
    AI ModelEmbedded (local inference)External (opencode CLI → cloud/local LLM)
    Tool AccessNone — user must manually searchAutonomous MCP tools (search, bookmarks, docs)
    Conversation StatePersistent — saved to disk, summary on overflowStateless — fresh process per conversation
    History ManagementFull persistence, summary extractionIn-memory only, session ID in opencode
    StartupImmediate (model loaded once)Spawns opencode process on first question
    Resource UsageGPU/CPU for local modelProcess memory + network (for cloud LLMs)
    IndependenceRelies on user to provide context via searchCan autonomously explore the case
    Error HandlingModel inference errors, timeoutProcess crashes, MCP failures, JVM issues
    ConfigurationIPED settings (model path, params)opencode.json + MCP server config
    Conversation ID Prefixnoneagent_
    +
    + + +

    16. Source Files

    + +
    +

    16.1 Java Files

    + + + + + + + + + +
    FilePath
    OpenCodeAgentService.javaiped-app/src/main/java/iped/app/ui/ai/agent/
    AgentConversation.javaiped-app/src/main/java/iped/app/ui/ai/model/
    AIAssistantController.javaiped-app/src/main/java/iped/app/ui/ai/controller/
    AIChatCoordinator.javaiped-app/src/main/java/iped/app/ui/ai/
    SidebarPanel.javaiped-app/src/main/java/iped/app/ui/ai/view/
    ConversationManager.javaiped-app/src/main/java/iped/app/ui/ai/context/
    ConversationPersistence.javaiped-app/src/main/java/iped/app/ui/ai/util/
    + +

    16.2 Python Files

    + + + + + + + + + + +
    FilePath
    main.pyiped-app/resources/scripts/mcp/iped-mcp-server/src/
    config.pyiped-app/resources/scripts/mcp/iped-mcp-server/src/
    jvm_bridge.pyiped-app/resources/scripts/mcp/iped-mcp-server/src/
    case_manager.pyiped-app/resources/scripts/mcp/iped-mcp-server/src/
    search.pyiped-app/resources/scripts/mcp/iped-mcp-server/src/tools/
    documents.pyiped-app/resources/scripts/mcp/iped-mcp-server/src/tools/
    bookmarks.pyiped-app/resources/scripts/mcp/iped-mcp-server/src/tools/
    sources.pyiped-app/resources/scripts/mcp/iped-mcp-server/src/tools/
    + +

    16.3 Configuration Files

    + + + +
    FilePath
    opencode.jsoniped-app/resources/scripts/mcp/iped-mcp-server/
    +
    + + +

    17. Conclusion

    + +
    +

    The IPED Agent module provides a powerful AI assistant that can autonomously explore forensic cases through MCP tool integration. Unlike the chatbot (which requires the user to manually provide context), the agent can independently search for evidence, examine documents, and build understanding of the case.

    + +

    Key architectural decisions:

    +
      +
    • External process isolation: The LLM runs in a separate process (opencode), preventing AI-related crashes from affecting IPED's stability
    • +
    • MCP standard: Using the Model Context Protocol ensures the tool interface is well-defined and could be used with other MCP-compatible AI assistants
    • +
    • JVM bridge: PyJnius allows direct access to IPED's Java API without reimplementing case access in Python
    • +
    • Stateless design: Each conversation is independent, simplifying process management at the cost of conversation persistence
    • +
    + +
    +For developers: When modifying agent behavior, focus on OpenCodeAgentService.java for process management and JSON-Lines parsing, and on the Python MCP server files for tool behavior. The Java controller layer (AIAssistantController, AIChatCoordinator) handles routing and is rarely modified. +
    +
    + + + From d51984ba3016a63625eed1084a8c120938ea6ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20V=C3=ADtor=20Motta=20Souto=20Maior?= Date: Wed, 15 Jul 2026 15:46:13 -0300 Subject: [PATCH 143/143] refactor: remove sse functiosn and update README --- .../scripts/mcp/iped-mcp-server/.env | 3 -- .../scripts/mcp/iped-mcp-server/README.md | 50 ++++--------------- .../scripts/mcp/iped-mcp-server/src/config.py | 2 - .../scripts/mcp/iped-mcp-server/src/main.py | 10 +--- 4 files changed, 11 insertions(+), 54 deletions(-) diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/.env b/iped-app/resources/scripts/mcp/iped-mcp-server/.env index 94fe1c655e..d53a78992d 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/.env +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/.env @@ -6,6 +6,3 @@ JAVA_HOME=C:\Program Files\BellSoft\LibericaJDK-11-Full\ # JVM maximum heap size JVM_MAX_HEAP=4g -# Host and port for MCP server (SSE transport, optional) -MCP_HOST=127.0.0.1 -MCP_PORT=8100 diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/README.md b/iped-app/resources/scripts/mcp/iped-mcp-server/README.md index 275b2cf60a..6fc9e042f0 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/README.md +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/README.md @@ -1,14 +1,15 @@ # IPED MCP Server -Servidor MCP (Model Context Protocol) para o [IPED](https://github.com/sepinf-inc/IPED) — ferramenta forense digital da Polícia Federal Brasileira. +Servidor MCP (Model Context Protocol) -Acessa os casos processados do IPED diretamente via Java (PyJnius), sem passar pela API REST. +Acessa os casos processados do IPED diretamente via Java (PyJnius) ## Requisitos - **Python 3.10+** - **Java JDK 11** com JavaFX (ex: Liberica JDK 11 Full) - **IPED** instalado e um caso já processado +- Dependencias instaladas ## Instalação @@ -19,7 +20,7 @@ pip install -r requirements.txt ## Configuração -Edite o arquivo `.env` (somente as variáveis opcionais): +- se quiser, edite o arquivo `.env`: ```env # Opcional: caminho do JDK (se diferente do JAVA_HOME do sistema) @@ -30,24 +31,18 @@ JAVA_HOME=C:\Program Files\Java\jdk-11 JVM_MAX_HEAP=4g ``` -> **Nota:** `IPED_HOME` e `CASE_PATH` são resolvidos automaticamente a partir da -> localização do arquivo `config.py` (caminhos relativos). Não é necessário -> configurá-los no `.env`. +- copie o arquivo opencode.json.example e renomeie para opencode.json +- adicione chave `apiKey` para se conectar com IA local ## Uso ### Iniciar servidor (modo stdio — padrão para OpenCode) -```bash -cd C:\iped\iped-mcp-server -python -m src.main -``` - -### Iniciar servidor (modo SSE — para testes HTTP) +** deve estar dentro de uma pasta de caso processado do IPED ** ```bash -cd C:\iped\iped-mcp-server -python -m src.main sse +cd iped-mcp-server +python -m src.main ``` ## Ferramentas MCP Disponíveis @@ -67,33 +62,6 @@ python -m src.main sse | `list_bookmarks()` | Lista nomes dos bookmarks | | `get_bookmark(name)` | Documentos em um bookmark | -## Integração com OpenCode - -Adicione ao seu `opencode.json`: - -```json -{ - "mcpServers": { - "iped": { - "command": "python", - "args": ["-m", "src.main"], - "cwd": "C:\\iped\\iped-mcp-server" - } - } -} -``` - -## Exemplos de Busca (sintaxe Lucene) - -```text -name:*.pdf → arquivos PDF -type:jpg → arquivos JPG -category:image → categoria imagem -created:[2023-01-01 TO 2023-12-31] → intervalo de data -content:"palavra-chave" → busca em texto completo -*.* → todos os itens -``` - ## Estrutura do Projeto ``` diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py index ea521a36ba..3db949037d 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py @@ -34,8 +34,6 @@ class Settings: case_path: Path = field(default_factory=lambda: Path(__file__).resolve().parents[5]) java_home: str = field(default_factory=_find_java_home) jvm_max_heap: str = field(default_factory=lambda: os.getenv("JVM_MAX_HEAP", "4g")) - mcp_host: str = field(default_factory=lambda: os.getenv("MCP_HOST", "127.0.0.1")) - mcp_port: int = field(default_factory=lambda: int(os.getenv("MCP_PORT", "8100"))) def validate(self) -> list[str]: errors = [] diff --git a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py index e68d8dc3aa..0d9b31e79f 100644 --- a/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py @@ -81,14 +81,8 @@ def main(): register_tools() - transport = sys.argv[1] if len(sys.argv) > 1 else "stdio" - - if transport == "sse": - logger.info("Starting MCP server on %s:%d (SSE)", settings.mcp_host, settings.mcp_port) - mcp.run(transport="sse", host=settings.mcp_host, port=settings.mcp_port) - else: - logger.info("Starting MCP server (stdio)") - mcp.run(transport="stdio") + logger.info("Starting MCP server (stdio)") + mcp.run(transport="stdio") if __name__ == "__main__":