diff --git a/iped-api/src/main/java/iped/properties/ExtraProperties.java b/iped-api/src/main/java/iped/properties/ExtraProperties.java index 252e840e6b..d882a0523f 100644 --- a/iped-api/src/main/java/iped/properties/ExtraProperties.java +++ b/iped-api/src/main/java/iped/properties/ExtraProperties.java @@ -188,6 +188,9 @@ 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"; + public static final String HASHDB_PREFIX = "hashDb:"; public static final String STATUS_PROPERTY = "status"; public static final String SET_PROPERTY = "set"; diff --git a/iped-app/resources/config/IPEDConfig.txt b/iped-app/resources/config/IPEDConfig.txt index b385a8632f..8687a18caa 100644 --- a/iped-app/resources/config/IPEDConfig.txt +++ b/iped-app/resources/config/IPEDConfig.txt @@ -147,6 +147,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/AIFiltersConfig.json b/iped-app/resources/config/conf/AIFiltersConfig.json index d06e0b1340..eff256cfe1 100644 --- a/iped-app/resources/config/conf/AIFiltersConfig.json +++ b/iped-app/resources/config/conf/AIFiltersConfig.json @@ -47,6 +47,14 @@ {"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": "*"}, {"name": "UFED Media Classification", "prefix": "UfedClassifier", "property": "ufed\\:mediaClasses", "value": "*", "dynamic": "true"}, diff --git a/iped-app/resources/config/conf/AISummarizationConfig.txt b/iped-app/resources/config/conf/AISummarizationConfig.txt new file mode 100644 index 0000000000..23d93eb2a7 --- /dev/null +++ b/iped-app/resources/config/conf/AISummarizationConfig.txt @@ -0,0 +1,33 @@ +############################################################################## +# Configuration file for AI-based summarization of evidence items. +############################################################################## + +# IP:PORT of the service/central node used by the AISummarizationTask implementation. +# remoteServiceAddress = 10.61.86.148:30603/summarization + +# 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 + +# Enables automatic summarization of externally parsed chats (UFED) using AI models. +enableExternalChatSummarization = true + +# Minimum item content length to perform summarization in characters +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 + +# 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"] diff --git a/iped-app/resources/config/conf/TaskInstaller.xml b/iped-app/resources/config/conf/TaskInstaller.xml index a17823f825..839e586686 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/localization/iped-ai-filters.properties b/iped-app/resources/localization/iped-ai-filters.properties index c082dfaba0..f31aa7c2fa 100644 --- a/iped-app/resources/localization/iped-ai-filters.properties +++ b/iped-app/resources/localization/iped-ai-filters.properties @@ -38,6 +38,15 @@ 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 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..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,15 @@ 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 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..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,15 @@ 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 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..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,15 @@ 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 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..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,15 @@ 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 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..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,15 @@ 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 UfedClassifier.UFED\ Media\ Classification=UFED Classificação de Mídia diff --git a/iped-app/resources/localization/iped-desktop-messages.properties b/iped-app/resources/localization/iped-desktop-messages.properties index dfa6776be1..42d0e00da8 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 @@ -218,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.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 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/resources/localization/iped-viewer-messages.properties b/iped-app/resources/localization/iped-viewer-messages.properties index 1741cebdee..8717101bce 100644 --- a/iped-app/resources/localization/iped-viewer-messages.properties +++ b/iped-app/resources/localization/iped-viewer-messages.properties @@ -129,3 +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 8830ddb35e..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,3 +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 ddd743b2ff..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,3 +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 d269a229d6..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,3 +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 63ab19550b..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,3 +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 8bf498d1b5..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,3 +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-app/resources/scripts/mcp/iped-mcp-server/.env b/iped-app/resources/scripts/mcp/iped-mcp-server/.env new file mode 100644 index 0000000000..d53a78992d --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/.env @@ -0,0 +1,8 @@ +# IPED MCP Server Configuration + +# Optional: Custom JAVA_HOME +JAVA_HOME=C:\Program Files\BellSoft\LibericaJDK-11-Full\ + +# JVM maximum heap size +JVM_MAX_HEAP=4g + 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..6fc9e042f0 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/README.md @@ -0,0 +1,84 @@ +# IPED MCP Server + +Servidor MCP (Model Context Protocol) + +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 + +```bash +cd iped-mcp-server +pip install -r requirements.txt +``` + +## Configuração + +- se quiser, edite o arquivo `.env`: + +```env +# 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 +``` + +- 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) + +** deve estar dentro de uma pasta de caso processado do IPED ** + +```bash +cd iped-mcp-server +python -m src.main +``` + +## 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_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 | +| `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 | + +## 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..0c64a45714 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/case_manager.py @@ -0,0 +1,347 @@ +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: + 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: + 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"] = [] + + 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]: + 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 + + 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/config.py b/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py new file mode 100644 index 0000000000..3db949037d --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/config.py @@ -0,0 +1,104 @@ +import os +import re +import shutil +from pathlib import Path +from dataclasses import dataclass, field +from dotenv import load_dotenv + +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=_find_java_home) + jvm_max_heap: str = field(default_factory=lambda: os.getenv("JVM_MAX_HEAP", "4g")) + + def validate(self) -> list[str]: + errors = [] + + if not self.iped_home.is_dir(): + errors.append( + f"IPED_HOME directory does not exist: {self.iped_home}. " + f"Check the relative path." + ) + + if not self.case_path.exists(): + errors.append( + f"CASE_PATH does not exist: {self.case_path}. Check the relative path." + ) + + java = self.java_home + if not java: + errors.append( + "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" + 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..0d9b31e79f --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/main.py @@ -0,0 +1,89 @@ +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 .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_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) + 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. 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) + + +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() + + 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..fb5a38d947 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/documents.py @@ -0,0 +1,112 @@ +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 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) + + +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 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}" + + +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) + """ + 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: + 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) 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..9a4635bb04 --- /dev/null +++ b/iped-app/resources/scripts/mcp/iped-mcp-server/src/tools/search.py @@ -0,0 +1,45 @@ +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) + + +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() 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() diff --git a/iped-app/resources/scripts/tasks/AISummarizationTask.py b/iped-app/resources/scripts/tasks/AISummarizationTask.py new file mode 100644 index 0000000000..ad8577fc53 --- /dev/null +++ b/iped-app/resources/scripts/tasks/AISummarizationTask.py @@ -0,0 +1,677 @@ +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 +from datetime import datetime +from typing import List, Any, Dict, Tuple, Optional +from iped.exception import IPEDException + +# configuration properties +enableProp = 'enableAISummarization' +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' + +# Remote service communication parameters +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 + +# 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' +questionAttributesProp = 'questionAttributes' + + + +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 +#------------------------------------------------------- + +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 _fmt_error(res: Dict[str, Any]) -> str: + """ + 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) + + +#-------------------------------------------------------- +# Remote service communication +#-------------------------------------------------------- + +def create_summaries_request( + msgs: list[dict], + base_url: str = "127.0.0.1:1111", + *, + questions: Optional[List[str]] = None, # NEW + 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. + - Other errors: retry up to MAX_ATTEMPTS_NONBUSY with NONBUSY_SLEEP between attempts. + + NOTE: Logging is improved; logic is unchanged. + """ + #Trocar para https + url = f"https://{base_url}/api/create_summaries_from_msgs" + attempts_other = 0 + + while True: + try: + payload: Dict[str, Any] = {"msgs": msgs} + if questions: # NEW + payload["questions"] = questions + resp = requests.post(url, json=payload, verify=False, timeout=(CONNECTION_TIMEOUT, REQUEST_TIMEOUT)) + res = _normalize(resp) + except requests.exceptions.Timeout: + res = { + "ok": False, + "code": "TIMEOUT", + "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, + "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), + } + except Exception as e: + res = { + "ok": False, + "code": "UNKNOWN_ERROR", + "http_status": 500, + "message": str(e), + } + + 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 - tried {attempts_other} times - {_fmt_error(res)}") + res["ok"] = False + return res + + # Intermediate warning with nice formatting + logger.warn(f"[AISummarizationTask]: Warning - tried {attempts_other} times - {_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"). + Se não conseguir entender, devolve o texto original. + """ + # 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: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 -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*. + 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) -> Tuple[List[Dict], int]: + + 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_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()}") + 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 ) + # -------------------------------------------------------------------# + content = "" + kind = "" + + i_tag = msg_div.find("i") + 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" + + + # -------------------------------------------------------------------# + # 2) anexo (áudio / vídeo / outro) + # -------------------------------------------------------------------# + if not content and not kind: + #kind = "other" + + # áudio ➜ ícone
+ if msg_div.find("div", class_="audioImg"): + kind = "audio" + elif msg_div.find("div", class_="imageImg"): + kind = "image" + elif msg_div.find("div", class_="videoImg"): + kind = "video" + # vídeo ou imagem ➜ thumbnail + else: + thumb = msg_div.find("img", class_="thumb") + if thumb: + title = (thumb.get("title") or "").lower() + if "video" in title: + kind = "video" + elif "image" in title: + kind = "image" + 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 and not kind: + content = _extract_text_nodes(msg_div) + kind = "text" + + # ainda vazio? provavelmente só thumbs ou attachments sem link → pula + if kind in ("text", "audio transcription"): + content = (content or "").strip() + if not content: + continue + + len_mgs_content = len_mgs_content + len(content) + + msgs.append( + { + "id":msg_id, + "content": content, + "timestamp": timestamp, + "direction": direction, + "name": name, + "forwarded": forwarded, + "kind": kind + } + ) + + return msgs, len_mgs_content + + + + +# 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: + + + def __init__(self): + self.enabled = False + self.remoteServiceAddress = None + self.enableInternalSummarizationProp = False + 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] = [] + self.questionAttributes: List[str] = [] + + return + + # Returns if this task is enabled or not. This could access options read by init() method. + def isEnabled(self): + return self.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) + self.enabled = taskConfig.isEnabled() + if not self.enabled: + return + extraProps = taskConfig.getConfiguration() + 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 + 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" + + 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" + 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]: 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]: 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 + + + def processChat(self, item): + from iped.properties import ExtraProperties + 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() + 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 == 0 or total_len < self.minimumContentLength: + return + + questions = None + if self.enableChatAnalysis and self.questions: + questions = self.questions + + res = create_summaries_request( + msgs, + self.remoteServiceAddress, + 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"]: + 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')}") + + + #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 + + #item.setExtraAttribute(ExtraProperties.SUMMARY, 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] = [] + chunk_ids: 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) + + # --- 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") + 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(int(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 + + # Store all chunk summaries (same attribute as before) + item.setExtraAttribute(ExtraProperties.SUMMARY, chunk_summaries) + + # Store 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(): + 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}" + ) + + + # 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 self.enabled: + return + + # 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 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. + # 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/App.java b/iped-app/src/main/java/iped/app/ui/App.java index 89e7442087..f6338a9e3f 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; @@ -210,7 +211,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 +281,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$ @@ -539,6 +540,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); @@ -582,6 +596,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(); @@ -817,6 +832,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); @@ -909,6 +925,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/java/iped/app/ui/MenuClass.java b/iped-app/src/main/java/iped/app/ui/MenuClass.java index 4cea5aa811..4790bfaa08 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,8 @@ import java.awt.event.ActionEvent; 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; @@ -28,8 +29,10 @@ import javax.swing.JMenuItem; import javax.swing.JPopupMenu; import javax.swing.JRadioButtonMenuItem; +import javax.swing.JTable; import javax.swing.KeyStroke; +import iped.app.ui.ai.view.AIAssistantPanel; 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, addAllHighlightedToAIContext, addAllCheckedToAIContext, navigateToParentChat, pinFirstColumns, similarImagesCurrent, similarImagesExternal, similarFacesCurrent, similarFacesExternal, toggleTimelineView, uiZoom, catIconSize, savePanelsLayout, loadPanelsLayout; MenuListener menuListener = new MenuListener(this); @@ -330,6 +333,40 @@ 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: Add Highlighted + addAllHighlightedToAIContext = new JMenuItem(Messages.getString("MenuClass.AddAllHighlightedToAIContext")); + try { + addAllHighlightedToAIContext.setIcon(iped.utils.IconUtil.getToolbarIcon("ai-assistant", App.resPath)); + } catch (Exception ignored) {} + + addAllHighlightedToAIContext.addActionListener(e -> { + List itemsToAdd = getHighlightedItems(); + + if (itemsToAdd.isEmpty() && item != null) { + itemsToAdd.add(item); + } + + if (!itemsToAdd.isEmpty()) { + AIAssistantPanel.getInstance().addItemsToContext(itemsToAdd); + } + }); + 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)); + } catch (Exception ignored) {} + + addAllCheckedToAIContext.addActionListener(e -> { + List itemsToAdd = getCheckedItems(); + + if (!itemsToAdd.isEmpty()) { + AIAssistantPanel.getInstance().addItemsToContext(itemsToAdd); + } + }); + this.add(addAllCheckedToAIContext); this.addSeparator(); @@ -337,6 +374,53 @@ public void actionPerformed(ActionEvent e) { createReport.addActionListener(menuListener); this.add(createReport); + } + + // Helper Methods moved outside the constructor + private List getCheckedItems() { + LinkedHashSet selectedItems = new LinkedHashSet<>(); + JTable table = App.get().getResultsTable(); + + if (table != null && !isTreeMenu) { + // 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)); + } + } + } + } + + 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. + 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/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/ViewerController.java b/iped-app/src/main/java/iped/app/ui/ViewerController.java index 33fd6e7147..79aba437cf 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) @@ -377,6 +380,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 SummaryViewer) continue; + if (viewer.isSupportedType(contentType, true)) { if (viewer instanceof MetadataViewer) { if (((MetadataViewer) viewer).isMetadataEntry(contentType)) { @@ -416,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 +} 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..57704e7b51 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/AIChatCoordinator.java @@ -0,0 +1,311 @@ +package iped.app.ui.ai; + +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; +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.model.AgentConversation; +import iped.app.ui.ai.context.AIContextManager; +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; +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, + * 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. + *

+ */ +public class AIChatCoordinator { + + private final AIBackendService backendService; + private final AIWhatsappChatExtractor extractor; + + // 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<>(); + private final OpenCodeAgentService agentService = new OpenCodeAgentService(); + + /** + * Constructs a new coordinator. + * * @param backendService The backend 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 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), + * 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) { + + // Fetch only valid entries from the Context Manager + List validEntries = AIContextManager.getInstance().getContextEntriesForUI() + .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. + if (validEntries.isEmpty()) { + onError.accept("Please, add at least one valid file to context before asking."); + return; + } + + // Check if the context changed since the last question + List newContextIds = validEntries.stream() + .map(e -> e.getItem().getId()) + .collect(Collectors.toList()); + + // 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(() -> { + + // Flag used to discern whether the hashes and context ids need clearing + boolean initializationCompleted = !needsInitialization; + + try { + // Step A: Initialize the Chat + if (needsInitialization) { + uiCallback.accept("**[System]:** Initializing context...\n\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 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, 11 or more chunks) + AIInitMultiChatRequest request = AIPayloadFactory.buildMultiChatRequest(validEntries); + List hashes = backendService.initMultiChat(request); + currentChatHashes.addAll(hashes); + } + + // Update cache state + currentContextItemIds = newContextIds; + + Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); + 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(std); + } + + // Update the flag + initializationCompleted = true; + } + + // Step B: Stream the response + StringBuilder fullResponse = new StringBuilder(); + + // Route to the correct streaming endpoint + if (currentChatHashes.size() == 1) { + // Single chat stream + backendService.streamChatResponse(currentChatHashes.get(0), question, chatHistory, token -> { + uiCallback.accept(token); + fullResponse.append(token); + }); + } else if (currentChatHashes.size() > 1) { + // Multi chat stream + // 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); + 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."); + } + + uiCallback.accept("\n\n"); + + // Step C: Save the turn to history + chatHistory.add(new AIStreamChatRequest.AIMessage("user", question)); + 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) { + currentContextItemIds.clear(); + currentChatHashes.clear(); + } + } finally { + onComplete.run(); + } + }).start(); + } + + + /** + * 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) { + Conversation activeConv = ConversationManager.getInstance().getActiveConversation(); + + // 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(newSessionId); + ConversationPersistence.saveConversation(agentConv); + } + }; + + agentService.askAgentQuestion(question, uiCallback, onSessionIdFound, sessionId, onComplete, onError); + } + + public void clearHistory() { + this.chatHistory.clear(); + + // Also clear chat currentChatHashes and currentContextItemIds + currentChatHashes.clear(); + currentContextItemIds.clear(); + this.agentService.clearHistory(); + } + + /** + * 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())); + } + } + } + + // 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())); + } + } + } + } + + /** + * Calculates the total number of chunks across a list of chat files. + */ + public static 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 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..b6bda1d58c --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/agent/OpenCodeAgentService.java @@ -0,0 +1,350 @@ +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; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +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. + */ +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, String sessionId, Runnable onComplete, Consumer onError) { + new Thread(() -> { + try { + uiCallback.accept("**[Agent]:** Executando opencode...\n\n"); + + // 1. Locate the mcp-server directory dynamically + File mcpServerDir = findMcpServerDir(); + + // 2. Resolve the opencode executable path + String opencodeCmd = resolveOpencodeCommand(); + + // 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")) { + command.add("cmd.exe"); + command.add("/c"); + } + command.add(opencodeCmd); + command.add("run"); + command.add(question); + 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); + + // 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()); + } + + pb.redirectErrorStream(true); + Process process = pb.start(); + + // Close subprocess stdin to prevent it from blocking waiting for input + process.getOutputStream().close(); + + // 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; + + try (InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) { + char[] charBuffer = new char[1024]; + int charsRead; + while ((charsRead = reader.read(charBuffer)) != -1) { + String chunk = new String(charBuffer, 0, charsRead); + buffer.append(chunk); + + // 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 extractedSessionId = matcher.group(1); + onSessionIdFound.accept(extractedSessionId); + sessionIdExtracted = true; + } + } + + // 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); + } + } + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + String errMsg = "The opencode process terminated with error code: " + exitCode; + uiCallback.accept("\n\n**[Agent Error]:** " + errMsg + "\n"); + onError.accept(errMsg); + } else { + // 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 = "Agent Error: " + e.getMessage(); + uiCallback.accept("\n\n**[Agent Error]:** " + errMsg + "\n"); + onError.accept(errMsg); + } finally { + onComplete.run(); + } + }).start(); + } + + /** + * 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(); + 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 does not produce visible text directly + // The model appears in the regular text + } else if ("text".equals(type)) { + // Extract text's content + String text = extractTextContent(json); + if (text != null && !text.isEmpty()) { + output.append(text); + } + } else if ("tool_use".equals(type)) { + // 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)) { + // Ignore step_finish + } else { + // For other types, try to extract the text directly + String text = extractTextContent(json); + if (text != null && !text.isEmpty()) { + output.append(text); + } + } + } catch (Exception e) { + // If parsing fails, ignore this line + // This can happen with partial chunks + } + } + + return output.toString(); + } + + /** + * Extracts text content from the JSON, handling nested fields. + */ + private String extractTextContent(JsonObject json) { + // Try to extract directly from the "text" field + if (json.has("text")) { + return unescapeJsonString(json.get("text").getAsString()); + } + + // Try to extract from "part" -> "text" + if (json.has("part")) { + JsonObject part = json.getAsJsonObject("part"); + if (part.has("text")) { + return unescapeJsonString(part.get("text").getAsString()); + } + } + + // Try to extract from the "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()); + } + } + } + + // Try to extract from the "content" field + if (json.has("content")) { + return unescapeJsonString(json.get("content").getAsString()); + } + + // Try to extract from "part" -> "content" + if (json.has("part")) { + JsonObject part = json.getAsJsonObject("part"); + if (part.has("content")) { + return unescapeJsonString(part.get("content").getAsString()); + } + } + + return null; + } + + /** + * Extracts tool call information from the JSON. + * Format: "? toolName {arguments}" + */ + private String extractToolInfo(JsonObject json) { + // Extract from the "part" object + if (!json.has("part")) { + return null; + } + + JsonObject part = json.getAsJsonObject("part"); + String toolName = null; + String arguments = null; + + // 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(); + } + + // Try to extract the arguments + if (part.has("state")) { + JsonObject state = part.getAsJsonObject("state"); + if (state.has("input")) { + arguments = state.get("input").toString(); + } + } + + if (toolName == null) { + return null; + } + + // Format the output in the original style + StringBuilder result = new StringBuilder(); + result.append("[Agente]: ").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(); + } + + /** + * 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("\\\\", "\\"); + } + + private File findMcpServerDir() { + String appRoot = null; + try { + appRoot = iped.engine.config.Configuration.getInstance().appRoot; + } catch (Throwable t) { + // Configuration may not be active/initialized in tests or specific environments + } + + if (appRoot != null) { + // 1. Production/packaged installation path + File prodPath = new File(appRoot, "scripts/mcp/iped-mcp-server"); + if (prodPath.exists() && prodPath.isDirectory()) { + return prodPath; + } + + // 2. Development path + File devPath = new File(appRoot, "resources/scripts/mcp/iped-mcp-server"); + if (devPath.exists() && devPath.isDirectory()) { + return devPath; + } + } + + // 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; + } + + return new File("."); + } + + private String resolveOpencodeCommand() { + boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); + if (isWindows) { + // 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"); + if (npmOpencode.exists()) { + return npmOpencode.getAbsolutePath(); + } + } + + // 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"); + if (npmOpencode.exists()) { + return npmOpencode.getAbsolutePath(); + } + } + } + + // Fallback to the default global command resolved by the OS + return "opencode"; + } + + public void clearHistory() { + this.chatHistory.clear(); + } + + public List getChatHistory() { + return chatHistory; + } +} \ 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 new file mode 100644 index 0000000000..6f50e1da40 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendClient.java @@ -0,0 +1,430 @@ +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; + +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; +import java.util.List; +import java.util.ArrayList; + +/** + * 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 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; + + /** + * 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 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(); + } + + /** + * 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 { + try { + // 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 + HttpRequest request = HttpRequest.newBuilder() + .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()) + .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 history The previous messages of this same chat + * @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, List history, Consumer eventHandler) throws AIBackendException { + try { + // Construct the payload for the query using the specified DTO + AIStreamChatRequest payload = new AIStreamChatRequest(chatHash, question, history); + 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()) + .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 using helper method + processSseStream(response, eventHandler); + + } catch (Exception e) { + throw new AIBackendException("Failed to stream chat response: " + e.getMessage(), e); + } + } + + /** + * Synchronously initializes a multi-chat session + *

+ * 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()); + } + + // 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; + } + 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); + } + } + } + } + } + } +} 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..a4cc1a178d --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendConfig.java @@ -0,0 +1,53 @@ +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. + 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; + } + + /** + * 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://localhost:8000"); + String apiKey = System.getProperty("iped.ai.key", "change-me"); + return new AIBackendConfig(url, apiKey); + } +} \ 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..08155782d0 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIBackendService.java @@ -0,0 +1,71 @@ +package iped.app.ui.ai.backend; + +import java.util.function.Consumer; +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. + * @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 history The chat history composed of previous messages + * @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 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; + + // --- 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 diff --git a/iped-app/src/main/java/iped/app/ui/ai/backend/AIInitChatRequest.java b/iped-app/src/main/java/iped/app/ui/ai/backend/AIInitChatRequest.java new file mode 100644 index 0000000000..37250a358f --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIInitChatRequest.java @@ -0,0 +1,26 @@ +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 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 + */ + private final String chat_content; + + public AIInitChatRequest(String chat_content) { + this.chat_content = chat_content; + } + + public String getChatContent() { + return chat_content; + } +} 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/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; } + } +} 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..2be14e1f76 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIMultiChatStreamRequest.java @@ -0,0 +1,38 @@ +package iped.app.ui.ai.backend; + +import java.util.List; + +/** + * A Data Transfer Object (DTO) for streaming both Summarized and Full multi-chat responses + *

+ * Maps to the backend's MultiChatConversationRequest schema + *

+ */ +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; } +} + 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..5d133d3283 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/backend/AIStreamChatRequest.java @@ -0,0 +1,48 @@ +package iped.app.ui.ai.backend; + +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, List previousmessages) { + this.chat_hash = chatHash; + this.user_question = userQuestion; + this.previousmessages = previousmessages; + } + + // 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; } + } +} 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 new file mode 100644 index 0000000000..4f05a5ef5a --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/context/AIContextManager.java @@ -0,0 +1,222 @@ +package iped.app.ui.ai.context; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import javax.swing.SwingUtilities; +import javax.swing.event.EventListenerList; + +import iped.app.ui.ai.model.ContextFileEntry; +import iped.app.ui.ai.util.ContextItemValidator; +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; + + /** Invalid entries shown only in UI as feedback */ + private final List invalidEntries; + + /** Listener list for context change events */ + private final EventListenerList listeners; + + private final ContextItemValidator validator = new ContextItemValidator(); + + /** + * Private constructor to enforce singleton pattern. + */ + private AIContextManager() { + this.contextFiles = new CopyOnWriteArrayList<>(); + this.invalidEntries = 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; + } + List single = new ArrayList<>(1); + single.add(item); + addContextFiles(single); + } + + /** + * Removes a file from the context based on its ID. + * + * @param item the item to remove + */ + public void removeContextFile(IItem item) { + 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); + } + } + + /** + * Clears all context files. + */ + public void clearContext() { + if (!contextFiles.isEmpty() || !invalidEntries.isEmpty()) { + contextFiles.clear(); + invalidEntries.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); + } + + /** + * 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. + * + * @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 changedAny = false; + + for (IItem item : items) { + if (item == null) { + continue; + } + + String rejectionReason = validator.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()); + + if (!alreadyExists) { + contextFiles.add(item); + changedAny = true; + } + } + + if (changedAny) { + fireContextChanged(ContextChangeEvent.FILE_ADDED); + } + } + +} \ No newline at end of file diff --git a/iped-app/src/main/java/iped/app/ui/ai/context/ContextChangeEvent.java b/iped-app/src/main/java/iped/app/ui/ai/context/ContextChangeEvent.java new file mode 100644 index 0000000000..8cb5bf3628 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/context/ContextChangeEvent.java @@ -0,0 +1,46 @@ +package iped.app.ui.ai.context; + +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/context/ContextChangeListener.java b/iped-app/src/main/java/iped/app/ui/ai/context/ContextChangeListener.java new file mode 100644 index 0000000000..4f36dcac30 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/context/ContextChangeListener.java @@ -0,0 +1,21 @@ +package iped.app.ui.ai.context; + +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/context/ConversationManager.java b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java new file mode 100644 index 0000000000..489e7b5b0f --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/context/ConversationManager.java @@ -0,0 +1,131 @@ +package iped.app.ui.ai.context; + +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; +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<>(); + + // 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() { + 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; + // Safeguard: don't add null to the list if the active state is wiped + if (conversation != null && !conversations.contains(conversation)) { + conversations.add(0, 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 = isAgent ? new AgentConversation() : new StandardConversation(); + setActiveConversation(newConv); + return newConv; + } + + 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. + */ + 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())) { + autoGenerateTitle(activeConversation); + } + + // Save to disk asynchronously + final Conversation convToSave = activeConversation; + 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 new file mode 100644 index 0000000000..af3fb4460e --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/controller/AIAssistantController.java @@ -0,0 +1,691 @@ +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.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.SwingConstants; +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.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; +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()); + + 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(); + } + + 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 onNewAgentChatRequested() { + startNewAgentChat(); + } + + @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) { + 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); + } + }); + + // 2. Domain persistence logic + if (isSwitchingChats) return; // Abort if the system is switching chats + + Conversation activeConv = conversationManager.getActiveConversation(); + if (activeConv instanceof StandardConversation) { + StandardConversation stdConv = (StandardConversation) activeConv; + List currentIds = contextManager.getContextFiles().stream() + .map(IItem::getId) + .collect(Collectors.toList()); + + stdConv.setContextIds(currentIds); + stdConv.updateLastModified(); + + final Conversation convToSave = stdConv; + 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(); + mainView.setContextPanelVisible(true); + mainView.setTasksPanelVisible(true); + clearChatScreenAndMemory(); + contextManager.clearContext(); + sidebarView.updateConversationsList(conversationManager.getConversations()); + refreshChatArea(); + + addMessage("System", "Started a new conversation session.", "system"); + chatAreaView.getInputArea().requestFocusInWindow(); + } + + private void startNewAgentChat() { + conversationManager.startNewConversation(true); + mainView.setContextPanelVisible(false); + mainView.setTasksPanelVisible(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); + + 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); + mainView.setContextPanelVisible(!conv.isAgentConversation()); + mainView.setTasksPanelVisible(!conv.isAgentConversation()); + + if (coordinator != null) { + 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(); + refreshChatArea(); + sidebarView.setSelectedValue(conv, true); + return; + } + + contextManager.clearContext(); + + new Thread(() -> { + List restoredItems = new ArrayList<>(); + try { + 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); + } + } + } 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); + } + } + } + } + } 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) { + 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); + } + } finally { + isSwitchingChats = false; + } + }); + } + }).start(); + + if (chatAreaView != null) chatAreaView.forceDiscardStreaming(); + + refreshChatArea(); + 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(); + + 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()); + } + } + } + + if (newConversation instanceof StandardConversation) { + StandardConversation std = (StandardConversation) newConversation; + std.setContextIds(contextIds); + std.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; + + Conversation activeConv = conversationManager.getActiveConversation(); + if (activeConv == null) { + activeConv = conversationManager.startNewConversation(); + sidebarView.updateConversationsList(conversationManager.getConversations()); + } + + addMessage("You", text, "user"); + chatAreaView.getInputArea().setText(""); + mainView.setProcessing(true); + + AIChatMessage assistantDraft = AIChatMessage.create("Assistant", "", "assistant"); + chatAreaView.startMessageStreaming(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()); + } + + 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); + }) + ); + } + } + + private void addMessage(String sender, String message, String type) { + AIChatMessage chatMessage = AIChatMessage.create(sender, message, type); + conversationManager.addMessageToActive(chatMessage); + refreshChatArea(); + } + + 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); + + // 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.setContextEditLocked(hasMessages); + } + } + } + + 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.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."); + 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; + } + + /** + * 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 && 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; + } + + 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/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. +
+
+ + + 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..b966d1bc65 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/documentation/agent/Agent sequence diagrams.html @@ -0,0 +1,729 @@ + + + + + + Agent Sequence Diagrams + + + + + +

Agent Sequence Diagrams

+

+ 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 and quick-actions panel visibility (both hidden for + agent conversations). +

+

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

+

+ ChatAreaPanel: renders agent responses with + streaming. +

+

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

+
+ +
+

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. +

+
+ +
+

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->>MainPanel: setTasksPanelVisible(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 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. +

+
+
+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) +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: 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 + 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: 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 + +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 [[hash-chunkId]] tokens +OC-->>AgentSvc: JSON-Lines stdout with text content +
+
+
+ + + + +
+

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 + 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 7: 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->>MainPanel: setTasksPanelVisible(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 8: 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/chatbot/Chatbot Broader Project Report.html b/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot Broader Project Report.html new file mode 100644 index 0000000000..a1254994a1 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot Broader Project Report.html @@ -0,0 +1,777 @@ + + + + + + Chatbot Broader Project Report + + + + + + +

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. +

+
+
+ + + 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 new file mode 100644 index 0000000000..68970eab63 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot Complete Technical Documentation.html @@ -0,0 +1,1284 @@ + + + + + + Chatbot Complete Technical Documentation + + + + + + +

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.
  • +
  • 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 + 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_content": "..."}) 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 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).
+ +

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 citation tools are planned + but not yet implemented; the LLM may produce citation tokens + directly in its text output using tool result hashes. +
+ +
+
+ + + + +
+

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 new file mode 100644 index 0000000000..29992d48da --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/documentation/chatbot/Chatbot sequence diagrams.html @@ -0,0 +1,976 @@ + + + + + + Chatbot Sequence Diagrams + + + + + +

Chatbot Sequence Diagrams

+

+ 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) +
+
+
+ + + + + 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 69% 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 baaa3778dc..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; @@ -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/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/model/AIChatMessage.java b/iped-app/src/main/java/iped/app/ui/ai/model/AIChatMessage.java new file mode 100644 index 0000000000..41caa12181 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/model/AIChatMessage.java @@ -0,0 +1,47 @@ +package iped.app.ui.ai.model; + +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 create(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/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/ContextFileEntry.java b/iped-app/src/main/java/iped/app/ui/ai/model/ContextFileEntry.java new file mode 100644 index 0000000000..8492d1d406 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/model/ContextFileEntry.java @@ -0,0 +1,146 @@ +package iped.app.ui.ai.model; + +import iped.app.ui.ai.util.SummaryValueExtractor; +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; + + /** 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, SummaryValueExtractor.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, SummaryValueExtractor.extractSummary(item), false, reason); + } + + /** + * Returns the underlying {@link IItem}. + * + * @return the wrapped item + */ + 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}. + * + * @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() : ""; + } + + @Override + public String toString() { + if (!validForContext && validationReason != null && !validationReason.trim().isEmpty()) { + return getFileName() + " - " + validationReason; + } + if (hasSummary()) { + return getFileName() + " [Summary]"; + } + return getFileName(); + } + + @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 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..b1b8e2b43f --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/model/Conversation.java @@ -0,0 +1,55 @@ +package iped.app.ui.ai.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +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 messages; + + 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.messages = new ArrayList<>(); + } + + // 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; } + public void updateLastModified() { this.lastModified = System.currentTimeMillis(); } + + public List getMessages() { + if (messages == null) messages = new ArrayList<>(); + return messages; + } + public void setMessages(List messages) { this.messages = messages; } + + public abstract boolean isAgentConversation(); + + /** + * 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; + } +} 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/AIPayloadFactory.java b/iped-app/src/main/java/iped/app/ui/ai/util/AIPayloadFactory.java new file mode 100644 index 0000000000..0f6c1718cc --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/util/AIPayloadFactory.java @@ -0,0 +1,190 @@ +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.AIInitMultiChatFullRequest; +import iped.app.ui.ai.backend.AIInitMultiChatRequest.SummarizedChat; +import iped.app.ui.ai.util.AIWhatsappChatExtractor; +import iped.data.IItem; +import iped.properties.ExtraProperties; + +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 + IItem item = entry.getItem(); + + // Get the globally unique hash directly from the item + String chatId = item.getHash(); + + // Fallback to internal integer ID if the hash is missing + if (chatId == null || chatId.trim().isEmpty()) { + chatId = String.valueOf(item.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 = extractList(item, ExtraProperties.SUMMARY); + List summaryIdsList = extractList(item, 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); + 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); + } + + /** + * Builds a payload for the Full Multi-Chat initialization endpoint. + * This bypasses summaries entirely and extracts the raw HTML from the IPED items. + * + * @param contextEntries The valid files currently in the AI context. + * @return A populated request object containing a list of raw HTML strings. + * @throws IllegalArgumentException if no valid HTML content could be extracted. + */ + public static AIInitMultiChatFullRequest buildMultiChatFullRequest(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. + */ + private static 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; + } +} 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 new file mode 100644 index 0000000000..6ea31a4123 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/util/AIWhatsappChatExtractor.java @@ -0,0 +1,75 @@ +package iped.app.ui.ai.util; + +import iped.data.IItem; +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 + * 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 { + + private final String whatsAppChatContentType = WhatsAppParser.WHATSAPP_CHAT.toString(); + + /** + *

+ * 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 WhatsApp chat content; false otherwise. + */ + public boolean isWhatsAppChatType(IItem item) { + if (item == null) { + return false; + } + + if (item.getMediaType() != null && whatsAppChatContentType.equals(item.getMediaType().toString())) { + return true; + } + + return item.getMetadata() != null + && whatsAppChatContentType.equals(item.getMetadata().get(StandardParser.INDEXER_CONTENT_TYPE)); + } + + /** + * 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 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. + */ + public String extractHtml(IItem item) throws Exception { + if (!isWhatsAppChatType(item)) { + 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. + 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); + } + + 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/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; + } +} 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..29f2d007b9 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/util/ConversationPersistence.java @@ -0,0 +1,133 @@ +package iped.app.ui.ai.util; + +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; + +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() { + 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"); + } + + 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; + } + } + + /** + * Serializes a Conversation object to a distinct JSON file + */ + public static void saveConversation(Conversation conversation) { + File dir = getStorageDirectory(); + if (dir == null || conversation == null) return; + + 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); + } 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.startsWith("agent_")) && name.endsWith(".json") + ); + if (files != null) { + for (File file : files) { + try (FileReader reader = new FileReader(file)) { + 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); + } + } + } 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; + } + + /** + * Flags the conversation as deleted by removing, but does not permanently delete the file + */ + public static void deleteConversation(Conversation conversation) { + if (conversation == null) return; + conversation.setStatus("deleted"); + saveConversation(conversation); + } +} \ No newline at end of file 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 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 new file mode 100644 index 0000000000..54b2658fab --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIAssistantPanel.java @@ -0,0 +1,341 @@ +package iped.app.ui.ai.view; + +import java.awt.BorderLayout; +import java.awt.Cursor; +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; +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.App; +import iped.app.ui.Messages; +import iped.data.IItem; +import iped.app.ui.ai.controller.AIAssistantController; + +/** + * AI Assistant floating panel UI layer for IPED. + *

+ * 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 { + + 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; + + private JFrame frame; + private JSplitPane splitPane; + + private HeaderPanel headerPanel; + private SidebarPanel sidebarPanel; + private ContextPanel contextPanel; + private ChatAreaPanel chatAreaPanel; + private JPanel tasksPanel; + + private final AIAssistantController controller; + private boolean processing; + + private static AIAssistantPanel instance; + + /** + * Singleton instance ensures only one floating panel manager exists at a time. + */ + public static synchronized AIAssistantPanel getInstance() { + if (instance == null) { + instance = new AIAssistantPanel(); + } + return instance; + } + + /** + * Private constructor initializing the Controller to achieve Inversion of Control (IoC). + */ + private AIAssistantPanel() { + this.controller = new AIAssistantController(this); + createBaseFrame(); + this.controller.initialize(); + } + + private void createBaseFrame() { + String title = "AI Assistant"; + try { + title = Messages.getString("AIAssistant.Title"); + } catch (Exception e) {} + + 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 --- + } + + /** + * 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)); + + mainPanel.add(headerPanel, BorderLayout.NORTH); + + JPanel chatWorkspacePanel = new JPanel(new BorderLayout(5, 5)); + + JPanel centerPanel = new JPanel(new BorderLayout(5, 5)); + centerPanel.add(contextPanel, BorderLayout.NORTH); + centerPanel.add(chatAreaPanel, BorderLayout.CENTER); + centerPanel.add(tasksPanel, BorderLayout.EAST); + + chatWorkspacePanel.add(centerPanel, BorderLayout.CENTER); + + splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, sidebarPanel, chatWorkspacePanel); + splitPane.setContinuousLayout(true); + splitPane.setDividerSize(5); + splitPane.setBorder(null); + splitPane.setDividerLocation(220); + + mainPanel.add(splitPane, BorderLayout.CENTER); + + frame.getContentPane().add(mainPanel); + frame.pack(); + positionDialog(); + } + + /** + * 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); + } + } + + public boolean isProcessing() { + return processing; + } + + /** + * 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()); + } + + /** + * Performs visual shifting of the JSplitPane divider to hide or show the sidebar. + */ + public void toggleSidebar() { + if (sidebarPanel == null || splitPane == null) { + return; + } + if (sidebarPanel.isVisible()) { + sidebarPanel.setVisible(false); + splitPane.setDividerLocation(0); + splitPane.setDividerSize(0); + } else { + sidebarPanel.setVisible(true); + splitPane.setDividerSize(5); + splitPane.setDividerLocation(220); + } + } + + private void positionDialog() { + Rectangle screenBounds = resolvePreferredScreenBounds(); + + int height = (int) (screenBounds.height * HEIGHT_PERCENTAGE); + frame.setSize(PANEL_WIDTH + 150, height); + + int x = screenBounds.x + screenBounds.width - frame.getWidth() - HORIZONTAL_OFFSET; + int y = screenBounds.y + VERTICAL_OFFSET; + + if (y + frame.getHeight() > screenBounds.y + screenBounds.height) { + y = screenBounds.y + screenBounds.height - frame.getHeight(); + } + + frame.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 = frame.getBounds(); + if (!virtualBounds.intersects(currentBounds)) { + positionDialog(); + } + } + + /** + * Handles the asynchronous visibility initialization of the frame window. + */ + 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(); + } + }; + + if (SwingUtilities.isEventDispatchThread()) { + action.run(); + } else { + SwingUtilities.invokeLater(action); + } + } + + /** + * Alternates the frame state between hidden and visible. + */ + public void toggleVisibility() { + SwingUtilities.invokeLater(() -> { + if (frame.isVisible()) { + frame.setVisible(false); + } else { + showFrame(); + } + }); + } + + 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; + } + + public void setContextPanelVisible(boolean visible) { + if (contextPanel != null) { + contextPanel.setVisible(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}. + * + * @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 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 new file mode 100644 index 0000000000..60ba51bac6 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/view/AIMarkdownRenderer.java @@ -0,0 +1,778 @@ +package iped.app.ui.ai.view; + +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; + +import iped.app.ui.ai.context.AIContextManager; +import iped.app.ui.ai.model.ContextFileEntry; +import iped.app.ui.ai.model.AIChatMessage; + +/** + * Renders assistant chat messages with lightweight Markdown support. + */ +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; + 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 { + draftStartOffset = -1; + draftStartThinkingBlockId = -1; + nextThinkingBlockId = 0; + chatDocument.remove(0, chatDocument.getLength()); + for (AIChatMessage message : messages) { + appendMessageToDocument(message); + } + } catch (BadLocationException e) { + System.err.println("Error rendering chat: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * 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(); + draftStartThinkingBlockId = nextThinkingBlockId; + } else { + chatDocument.remove(draftStartOffset, chatDocument.getLength() - draftStartOffset); + if (draftStartThinkingBlockId >= 0) { + nextThinkingBlockId = draftStartThinkingBlockId; + } + } + + 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; + draftStartThinkingBlockId = -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; + 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. + */ + 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); + StyleConstants.setForeground(heading, new java.awt.Color(0x1a, 0x56, 0xb3)); + 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); + + 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); + + 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); + } + + /** + * 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, AttributeSet 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, AttributeSet container) throws BadLocationException { + String normalizedMarkdown = markdown.replace("\r\n", "\n").replace('\r', '\n'); + AttributeSet normalBaseStyle = combineStyles(createBaseStyle(false), container); + AttributeSet italicBaseStyle = combineStyles(createBaseStyle(true), container); + 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); + 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; + } + } + + 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, AttributeSet 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 thinkingContainer = combineStyles(createBaseStyle(false), chatArea.getStyle("thinking-body")); + appendMarkdown(content, thinkingContainer); + } + } + + /** + * 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, AttributeSet 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 tokenStart = text.indexOf("<<", index); + int boldStart = text.indexOf("**", index); + int italicStart = findItalicStart(text, index); + + int next = smallestPositive(tokenStart, 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 == 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()) { + String fallbackVisibleText = text.substring(tokenStart, end + 2); + String visibleText = resolveTokenVisibleText(tokenParts[0], fallbackVisibleText); + appendToken(visibleText, 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) { + 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; + } + + /** + * 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. + */ + private void appendToken(String visibleText, String hash, String chunkId, AttributeSet baseStyle) throws BadLocationException { + 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); + chatDocument.insertString(chatDocument.getLength(), visibleText, tokenStyle); + } +} 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..133cf84f54 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ChatAreaPanel.java @@ -0,0 +1,279 @@ +package iped.app.ui.ai.view; + +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; +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.StyledDocument; +import javax.swing.text.AttributeSet; +import javax.swing.text.Element; + +import iped.app.ui.ai.model.AIChatMessage; + +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; + + private final AIMarkdownRenderer markdownRenderer; + private final ChatStreamAnimator streamAnimator; + private AIChatMessage currentDraftMessage; + + 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)); + + this.markdownRenderer = new AIMarkdownRenderer(chatArea); + this.chatDocument = markdownRenderer.getDocument(); + + this.streamAnimator = new ChatStreamAnimator(() -> renderActiveDraft()); + + 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); + } + + 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() { + 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 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 (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 ? Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) + : Cursor.getDefaultCursor()); + } + + /** + * 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 MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() != 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; + } + + + Element element = chatDocument.getCharacterElement(offset); + AttributeSet attributes = element.getAttributes(); + Object tokenFlag = attributes.getAttribute(AIMarkdownRenderer.TOKEN_ATTRIBUTE); + + // 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; + } + + // 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(); + } + } + } + }); + } + + 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; + } +} 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 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..84e8667f6b --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/view/ContextPanel.java @@ -0,0 +1,304 @@ +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 iped.app.ui.ai.model.ContextFileEntry; +import iped.data.IItem; + +/** + * 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 { + + 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 contextTitleLabel; + private JLabel contextEmptyLabel; + private JLabel chatModeLabel; + private JButton clearContextButton; + + private final ContextListener listener; + private boolean isContextEditLocked = false; + + /** + * 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. + */ + private static final class ContextSummaryRow { + private final String text; + + private ContextSummaryRow(String text) { + this.text = text; + } + + @Override + public String toString() { + return text; + } + } + + /** + * 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(); + } + + private void configureLayout() { + setLayout(new BorderLayout(5, 5)); + 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)); + 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() { + 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 -> { + if (!isContextEditLocked && listener != null) { + listener.onClearContextRequested(); + } + }); + + JPanel actionPanel = new JPanel(new BorderLayout()); + actionPanel.add(clearContextButton, BorderLayout.NORTH); + + add(listContainer, BorderLayout.CENTER); + 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. + * + * 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(); + if (currentTitle != null) { + String baseTitle = currentTitle.replace(" [LOCKED]", ""); + String lockText = locked ? " [LOCKED]" : ""; + contextTitleLabel.setText(baseTitle + lockText); + } + + contextList.repaint(); + repaint(); + } + + 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()); + + // 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)); + + 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) { + // Ignore direct remove clicks while context editing is disabled for this conversation view + if (isContextEditLocked) return; + + 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; + + if (listener != null) { + listener.onRemoveFileRequested(((ContextFileEntry) value).getItem()); + } + } + }); + } + + /** + * 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 updateContextData(List entries) { + contextListModel.clear(); + + if (entries == null || entries.isEmpty()) { + contextEmptyLabel.setVisible(true); + contextList.setVisible(false); + clearContextButton.setEnabled(false); + contextTitleLabel.setText("Added Context (0 files)"); + } else { + contextEmptyLabel.setVisible(false); + contextList.setVisible(true); + // Disable "Clear Context" only for the current conversation's panel-level edit flow + clearContextButton.setEnabled(!isContextEditLocked); + + 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)); + } + + if (entries.size() > CONTEXT_VISIBLE_ITEMS) { + int hiddenCount = entries.size() - CONTEXT_VISIBLE_ITEMS; + String summaryText = "+ " + hiddenCount + " more items (" + validCount + " valid, " + invalidCount + " rejected)"; + contextListModel.addElement(new ContextSummaryRow(summaryText)); + } + + String lockText = isContextEditLocked ? " [LOCKED]" : ""; + if (invalidCount > 0) { + contextTitleLabel.setText("Added Context (" + validCount + " valid, " + invalidCount + " rejected)" + lockText); + } else { + contextTitleLabel.setText("Added Context (" + validCount + " valid)" + lockText); + } + } + + repaint(); + } +} \ No newline at end of file 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..7205141823 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/view/HeaderPanel.java @@ -0,0 +1,66 @@ +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; + +/** + * 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) { + + // Initialize the base JPanel with BorderLayout + super(new BorderLayout()); + + // Sidebar toggle button + JButton toggleSidebarBtn = new JButton("☰"); + toggleSidebarBtn.setMargin(new Insets(2, 6, 2, 6)); + toggleSidebarBtn.setFocusPainted(false); + toggleSidebarBtn.setToolTipText("Toggle Sidebar"); + + // Execute the action injected by the main class + toggleSidebarBtn.addActionListener(toggleSidebarListener); + + // Title label + JLabel titleLabel = new JLabel(titleText); + titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14)); + + // Title area (button + text) + JPanel titleArea = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 0)); + titleArea.add(toggleSidebarBtn); + titleArea.add(titleLabel); + + // Initialize status label + statusLabel = new JLabel("● Connected to local backend server"); + statusLabel.setForeground(new Color(0, 150, 0)); // Green for active + + // 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); + + // Add components to the main panel (this) + add(leftPanel, BorderLayout.WEST); + setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Color.LIGHT_GRAY)); + } + + /** + * Allows external controllers to update the backend status visual state. + */ + public void updateStatus(String text, Color color) { + statusLabel.setText(text); + statusLabel.setForeground(color); + } +} \ No newline at end of file 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..42cb194ba5 --- /dev/null +++ b/iped-app/src/main/java/iped/app/ui/ai/view/SidebarPanel.java @@ -0,0 +1,194 @@ +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.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.ListSelectionModel; + +import iped.app.ui.ai.model.Conversation; + +/** + * 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 { + + private JButton newChatButton; + private JList conversationList; + private DefaultListModel conversationListModel; + + private final Component parentFrame; + private final SidebarListener listener; + + /** + * 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 onNewAgentChatRequested(); + 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) { + this.parentFrame = parentFrame; + 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() { + // 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) 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.setPreferredSize(new Dimension(newChatButton.getWidth(), menu.getPreferredSize().height)); + menu.show(newChatButton, 0, newChatButton.getHeight()); + }); + add(newChatButton, BorderLayout.NORTH); + + // 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); + + 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; + + 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()); + + 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; + } + + if (selected != null && listener != null) { + listener.onConversationSelected(selected); + } + } + }); + } + + public JList getConversationList() { + return conversationList; + } + + public void setConversationList(JList conversationList) { + this.conversationList = conversationList; + } + + /** + * 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 updateConversationsList(List conversations) { + conversationListModel.clear(); + if (conversations != null) { + for (Conversation conv : conversations) { + conversationListModel.addElement(conv); + } + } + conversationList.repaint(); + } + + 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 && listener != null) { + listener.onDeleteRequested(conv); + } + } +} \ No newline at end of file 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 0000000000..62495b767a Binary files /dev/null and b/iped-app/src/main/resources/iped/app/ui/ai-assistant.png differ 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 0000000000..a0eab8001f Binary files /dev/null and b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Analyzed Chats.png differ 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 0000000000..cde5186aa3 Binary files /dev/null and b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.High.png differ 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 0000000000..0d2c6789a7 Binary files /dev/null and b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Low.png differ diff --git a/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Medium.png b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Medium.png new file mode 100644 index 0000000000..77449ca9d3 Binary files /dev/null and b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Medium.png differ 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 0000000000..7db4f359c6 Binary files /dev/null and b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Very High.png differ 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 0000000000..8e5360c5a8 Binary files /dev/null and b/iped-app/src/main/resources/iped/app/ui/filter/Analysis.Very Low.png differ 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 0000000000..d9cb032c84 Binary files /dev/null and b/iped-app/src/main/resources/iped/app/ui/filter/Summary.Summarized Chats.png differ 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(); 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 new file mode 100644 index 0000000000..a74ac7ad9d --- /dev/null +++ b/iped-viewers/iped-viewers-impl/src/main/java/iped/viewers/SummaryViewer.java @@ -0,0 +1,136 @@ +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; +import iped.utils.SimpleHTMLEncoder; +import iped.utils.UiUtil; +import iped.viewers.localization.Messages; + +/** + * Shows ExtraProperties.SUMMARY (array of strings) for the current item. + * Extends HtmlViewer to reuse hit highlighting & WebView plumbing. + */ +public class SummaryViewer extends HtmlViewer { + + @Override + public String getName() { + return Messages.getString("SummaryViewer.TabName"); + } + + @Override + public boolean isSupportedType(String contentType) { + // we gate by data presence + return true; + } + + @Override + public int getHitsSupported() { + return 1; + } + + /** Quick presence check so controller can decide tab visibility. */ + public boolean hasSummary(IStreamSource content) { + if (!(content instanceof IItemReader)) return false; + IItemReader item = (IItemReader) content; + + // Check extra attributes (preferred) + Object v = item.getExtraAttribute(ExtraProperties.SUMMARY); + if (v != null) return true; + + // Fallback: metadata bag + String[] vals = item.getMetadata().getValues(ExtraProperties.SUMMARY); + 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) || !hasSummary(content)) { + webEngine.loadContent(UiUtil.getUIEmptyHtml("[" + Messages.getString("SummaryViewer.NoSummary") + "]")); + return; + } + + IItemReader item = (IItemReader) content; + ArrayList chunks = new ArrayList<>(); + + Object value = item.getExtraAttribute(ExtraProperties.SUMMARY); + 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.SUMMARY); + if (vals != null) { + for (String s : vals) { + if (s != null) chunks.add(s); + } + } + } + + // 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("
").append(Messages.getString("SummaryViewer.Title")).append("
"); + + for (int i = 0; i < chunks.size(); i++) { + String c = SimpleHTMLEncoder.htmlEncode(chunks.get(i)).replaceAll("\n","
"); + html.append("
") + .append("
").append(c).append("
") + .append("
"); + } + html.append(""); + + webEngine.setJavaScriptEnabled(false); // not needed here + webEngine.loadContent(html.toString()); + }); + } +}