mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-05-19 07:54:46 +00:00
feat: add conversations tags support
This commit is contained in:
parent
465feafc9f
commit
901ce5a01e
25 changed files with 921 additions and 33 deletions
|
|
@ -12,7 +12,7 @@ jsoup = "1.19.1"
|
|||
jtokkit = "1.1.0"
|
||||
junit = "5.12.1"
|
||||
kotlin = "2.1.20"
|
||||
llm-client = "0.8.43"
|
||||
llm-client = "0.8.44"
|
||||
okio = "3.10.2"
|
||||
tree-sitter = "0.24.5"
|
||||
grpc = "1.71.0"
|
||||
|
|
|
|||
|
|
@ -16,11 +16,16 @@ public class Message {
|
|||
private String prompt;
|
||||
private String response;
|
||||
private List<String> referencedFilePaths;
|
||||
private List<UUID> conversationsHistoryIds;
|
||||
private String imageFilePath;
|
||||
private boolean webSearchIncluded;
|
||||
private DocumentationDetails documentationDetails;
|
||||
private String personaName;
|
||||
|
||||
public Message() {
|
||||
this.id = UUID.randomUUID();
|
||||
}
|
||||
|
||||
public Message(String prompt, String response) {
|
||||
this(prompt);
|
||||
this.response = response;
|
||||
|
|
@ -60,6 +65,14 @@ public class Message {
|
|||
this.referencedFilePaths = referencedFilePaths;
|
||||
}
|
||||
|
||||
public List<UUID> getConversationsHistoryIds() {
|
||||
return conversationsHistoryIds;
|
||||
}
|
||||
|
||||
public void setConversationsHistoryIds(List<UUID> conversationsHistoryIds) {
|
||||
this.conversationsHistoryIds = conversationsHistoryIds;
|
||||
}
|
||||
|
||||
public @Nullable String getImageFilePath() {
|
||||
return imageFilePath;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,11 +37,13 @@ import ee.carlrobert.codegpt.toolwindow.ui.ChatToolWindowLandingPanel;
|
|||
import ee.carlrobert.codegpt.toolwindow.ui.ResponseMessagePanel;
|
||||
import ee.carlrobert.codegpt.toolwindow.ui.UserMessagePanel;
|
||||
import ee.carlrobert.codegpt.ui.OverlayUtil;
|
||||
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor;
|
||||
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel;
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.EditorTagDetails;
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails;
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.FolderTagDetails;
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.GitCommitTagDetails;
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.HistoryTagDetails;
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.PersonaTagDetails;
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails;
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager;
|
||||
|
|
@ -169,6 +171,7 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
.conversationType(conversationType)
|
||||
.imageDetailsFromPath(CodeGPTKeys.IMAGE_ATTACHMENT_FILE_PATH.get(project))
|
||||
.referencedFiles(getReferencedFiles(selectedTags))
|
||||
.history(getHistory(getSelectedTags()))
|
||||
.psiStructure(psiStructure);
|
||||
|
||||
findTagOfType(selectedTags, PersonaTagDetails.class)
|
||||
|
|
@ -189,6 +192,32 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
.toList();
|
||||
}
|
||||
|
||||
private List<UUID> getConversationHistoryIds(List<? extends TagDetails> tags) {
|
||||
return tags.stream()
|
||||
.map(it -> {
|
||||
if (it instanceof HistoryTagDetails tagDetails) {
|
||||
return tagDetails.getConversationId();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<Conversation> getHistory(List<? extends TagDetails> tags) {
|
||||
return tags.stream()
|
||||
.map(it -> {
|
||||
if (it instanceof HistoryTagDetails tagDetails) {
|
||||
return ConversationTagProcessor.Companion.getConversation(
|
||||
tagDetails.getConversationId());
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private VirtualFile getVirtualFile(TagDetails tag) {
|
||||
VirtualFile virtualFile = null;
|
||||
if (tag.getSelected()) {
|
||||
|
|
@ -392,6 +421,11 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
messageBuilder.withReferencedFiles(referencedFiles);
|
||||
}
|
||||
|
||||
List<UUID> conversationHistoryIds = getConversationHistoryIds(appliedTags);
|
||||
if (!conversationHistoryIds.isEmpty()) {
|
||||
messageBuilder.withConversationHistoryIds(conversationHistoryIds);
|
||||
}
|
||||
|
||||
String attachedImagePath = CodeGPTKeys.IMAGE_ATTACHMENT_FILE_PATH.get(project);
|
||||
if (attachedImagePath != null) {
|
||||
messageBuilder.withImage(attachedImagePath);
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ class PsiStructureRepository(
|
|||
// Maybe need recursive find all files
|
||||
is FolderTagDetails -> null
|
||||
|
||||
is HistoryTagDetails -> null
|
||||
is EditorSelectionTagDetails -> null
|
||||
is DocumentationTagDetails -> null
|
||||
is CurrentGitChangesTagDetails -> null
|
||||
|
|
@ -211,6 +212,7 @@ class PsiStructureRepository(
|
|||
// Maybe need recursive find all files
|
||||
is FolderTagDetails -> false
|
||||
|
||||
is HistoryTagDetails -> false
|
||||
is DocumentationTagDetails -> false
|
||||
is CurrentGitChangesTagDetails -> false
|
||||
is GitCommitTagDetails -> false
|
||||
|
|
@ -235,6 +237,7 @@ class PsiStructureRepository(
|
|||
// Maybe need recursive find all files
|
||||
is FolderTagDetails -> null
|
||||
|
||||
is HistoryTagDetails -> null
|
||||
is DocumentationTagDetails -> null
|
||||
is CurrentGitChangesTagDetails -> null
|
||||
is GitCommitTagDetails -> null
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class ChatCompletionParameters private constructor(
|
|||
var sessionId: UUID?,
|
||||
var retry: Boolean,
|
||||
var imageDetails: ImageDetails?,
|
||||
var history: List<Conversation>?,
|
||||
var referencedFiles: List<ReferencedFile>?,
|
||||
var personaDetails: PersonaDetails?,
|
||||
var psiStructure: Set<ClassStructure>?,
|
||||
|
|
@ -42,6 +43,7 @@ class ChatCompletionParameters private constructor(
|
|||
private var conversationType: ConversationType = ConversationType.DEFAULT
|
||||
private var retry: Boolean = false
|
||||
private var imageDetails: ImageDetails? = null
|
||||
private var history: List<Conversation>? = null
|
||||
private var referencedFiles: List<ReferencedFile>? = null
|
||||
private var personaDetails: PersonaDetails? = null
|
||||
private var psiStructure: Set<ClassStructure>? = null
|
||||
|
|
@ -64,6 +66,8 @@ class ChatCompletionParameters private constructor(
|
|||
|
||||
fun gitDiff(gitDiff: String) = apply { this.gitDiff = gitDiff }
|
||||
|
||||
fun history(history: List<Conversation>?) = apply { this.history = history }
|
||||
|
||||
fun referencedFiles(referencedFiles: List<ReferencedFile>?) =
|
||||
apply { this.referencedFiles = referencedFiles }
|
||||
|
||||
|
|
@ -79,6 +83,7 @@ class ChatCompletionParameters private constructor(
|
|||
sessionId,
|
||||
retry,
|
||||
imageDetails,
|
||||
history,
|
||||
referencedFiles,
|
||||
personaDetails,
|
||||
psiStructure,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import ee.carlrobert.codegpt.completions.factory.OpenAIRequestFactory.Companion.
|
|||
import ee.carlrobert.codegpt.psistructure.ClassStructureSerializer
|
||||
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
|
||||
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTServiceSettings
|
||||
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor
|
||||
import ee.carlrobert.codegpt.util.file.FileUtil
|
||||
import ee.carlrobert.llm.client.codegpt.request.chat.*
|
||||
import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage
|
||||
|
|
@ -22,7 +23,9 @@ class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructure
|
|||
val model = service<CodeGPTServiceSettings>().state.chatCompletionSettings.model
|
||||
val configuration = service<ConfigurationSettings>().state
|
||||
val requestBuilder: ChatCompletionRequest.Builder =
|
||||
ChatCompletionRequest.Builder(buildOpenAIMessages(model, params, emptyList()))
|
||||
ChatCompletionRequest.Builder(
|
||||
buildOpenAIMessages(model, params, emptyList(), emptyList())
|
||||
)
|
||||
.setModel(model)
|
||||
.setSessionId(params.sessionId)
|
||||
.setStream(true)
|
||||
|
|
@ -74,11 +77,15 @@ class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructure
|
|||
)
|
||||
}.orEmpty()
|
||||
|
||||
val contextFilesWithPsi = contextFiles + psiContext
|
||||
if (contextFilesWithPsi.isNotEmpty()) {
|
||||
requestBuilder.setContext(AdditionalRequestContext(contextFilesWithPsi))
|
||||
val conversationsHistory = params.history?.joinToString("\n\n") {
|
||||
ConversationTagProcessor.formatConversation(it)
|
||||
}
|
||||
|
||||
requestBuilder.setContext(
|
||||
AdditionalRequestContext(
|
||||
contextFiles + psiContext,
|
||||
conversationsHistory
|
||||
)
|
||||
)
|
||||
return requestBuilder.build()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class CustomOpenAIRequestFactory : BaseRequestFactory() {
|
|||
.active
|
||||
val request = buildCustomOpenAIChatCompletionRequest(
|
||||
activeService.chatCompletionSettings,
|
||||
OpenAIRequestFactory.buildOpenAIMessages(null, params, params.referencedFiles, params.psiStructure),
|
||||
OpenAIRequestFactory.buildOpenAIMessages(null, params, params.referencedFiles, params.history, params.psiStructure),
|
||||
true,
|
||||
getCredential(CredentialKey.CustomServiceApiKey(activeService.name.orEmpty()))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class OllamaRequestFactory : BaseRequestFactory() {
|
|||
model = model,
|
||||
callParameters = params,
|
||||
referencedFiles = params.referencedFiles,
|
||||
conversationsHistory = params.history,
|
||||
psiStructure = params.psiStructure,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.intellij.openapi.vfs.readText
|
|||
import ee.carlrobert.codegpt.EncodingManager
|
||||
import ee.carlrobert.codegpt.ReferencedFile
|
||||
import ee.carlrobert.codegpt.completions.*
|
||||
import ee.carlrobert.codegpt.conversations.Conversation
|
||||
import ee.carlrobert.codegpt.conversations.ConversationsState
|
||||
import ee.carlrobert.codegpt.psistructure.models.ClassStructure
|
||||
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
|
||||
|
|
@ -13,6 +14,7 @@ import ee.carlrobert.codegpt.settings.prompts.CoreActionsState
|
|||
import ee.carlrobert.codegpt.settings.prompts.PromptsSettings
|
||||
import ee.carlrobert.codegpt.settings.prompts.addProjectPath
|
||||
import ee.carlrobert.codegpt.settings.service.openai.OpenAISettings
|
||||
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor
|
||||
import ee.carlrobert.codegpt.util.file.FileUtil.getImageMediaType
|
||||
import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.*
|
||||
import ee.carlrobert.llm.client.openai.completion.request.*
|
||||
|
|
@ -135,12 +137,14 @@ class OpenAIRequestFactory : CompletionRequestFactory {
|
|||
model: String?,
|
||||
callParameters: ChatCompletionParameters,
|
||||
referencedFiles: List<ReferencedFile>? = null,
|
||||
conversationsHistory: List<Conversation>? = null,
|
||||
psiStructure: Set<ClassStructure>? = null
|
||||
): List<OpenAIChatCompletionMessage> {
|
||||
val messages = buildOpenAIChatMessages(
|
||||
model = model,
|
||||
callParameters = callParameters,
|
||||
referencedFiles = referencedFiles ?: callParameters.referencedFiles,
|
||||
conversationsHistory = conversationsHistory ?: callParameters.history,
|
||||
psiStructure = psiStructure,
|
||||
)
|
||||
|
||||
|
|
@ -178,6 +182,7 @@ class OpenAIRequestFactory : CompletionRequestFactory {
|
|||
model: String?,
|
||||
callParameters: ChatCompletionParameters,
|
||||
referencedFiles: List<ReferencedFile>? = null,
|
||||
conversationsHistory: List<Conversation>? = null,
|
||||
psiStructure: Set<ClassStructure>? = null
|
||||
): MutableList<OpenAIChatCompletionMessage> {
|
||||
val message = callParameters.message
|
||||
|
|
@ -187,20 +192,18 @@ class OpenAIRequestFactory : CompletionRequestFactory {
|
|||
val selectedPersona = service<PromptsSettings>().state.personas.selectedPersona
|
||||
if (callParameters.conversationType == ConversationType.DEFAULT && !selectedPersona.disabled) {
|
||||
val sessionPersonaDetails = callParameters.personaDetails
|
||||
if (sessionPersonaDetails == null) {
|
||||
messages.add(
|
||||
OpenAIChatCompletionStandardMessage(
|
||||
role,
|
||||
selectedPersona.instructions?.addProjectPath()
|
||||
)
|
||||
)
|
||||
val instructions = sessionPersonaDetails?.instructions?.addProjectPath()
|
||||
?: selectedPersona.instructions?.addProjectPath()
|
||||
val history = if (conversationsHistory.isNullOrEmpty()) {
|
||||
""
|
||||
} else {
|
||||
messages.add(
|
||||
OpenAIChatCompletionStandardMessage(
|
||||
role,
|
||||
sessionPersonaDetails.instructions.addProjectPath()
|
||||
)
|
||||
)
|
||||
conversationsHistory.joinToString("\n\n") {
|
||||
ConversationTagProcessor.formatConversation(it)
|
||||
}
|
||||
}
|
||||
|
||||
if (instructions != null) {
|
||||
messages.add(OpenAIChatCompletionStandardMessage(role, instructions + "\n" + history))
|
||||
}
|
||||
}
|
||||
if (callParameters.conversationType == ConversationType.REVIEW_CHANGES) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import ee.carlrobert.codegpt.ReferencedFile
|
|||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
|
||||
import java.util.*
|
||||
|
||||
class MessageBuilder(private val project: Project, private val text: String) {
|
||||
private val message = Message("")
|
||||
|
|
@ -24,6 +25,13 @@ class MessageBuilder(private val project: Project, private val text: String) {
|
|||
return this
|
||||
}
|
||||
|
||||
fun withConversationHistoryIds(conversationHistoryIds: List<UUID>): MessageBuilder {
|
||||
if (conversationHistoryIds.isNotEmpty()) {
|
||||
message.conversationsHistoryIds = conversationHistoryIds
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun withImage(attachedImagePath: String): MessageBuilder {
|
||||
message.imageFilePath = attachedImagePath
|
||||
return this
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.intellij.openapi.Disposable
|
|||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
|
|
@ -23,10 +24,15 @@ import ee.carlrobert.codegpt.Icons
|
|||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
import ee.carlrobert.codegpt.events.WebSearchEventDetails
|
||||
import ee.carlrobert.codegpt.settings.GeneralSettings
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowContentManager
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.ImageAccordion
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.SelectedFilesAccordion
|
||||
import ee.carlrobert.codegpt.ui.IconActionButton
|
||||
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor
|
||||
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor.Companion.formatConversation
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
|
||||
import ee.carlrobert.codegpt.util.MarkdownUtil
|
||||
import java.awt.Image
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.ActionListener
|
||||
|
|
@ -170,6 +176,29 @@ class UserMessagePanel(
|
|||
)
|
||||
}
|
||||
|
||||
message.conversationsHistoryIds?.let { ids ->
|
||||
additionalContextPanel.add(JPanel().apply {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
isOpaque = false
|
||||
ids.forEach {
|
||||
ConversationTagProcessor.getConversation(it)?.let { conversation ->
|
||||
val title = HistoryActionItem.getConversationTitle(conversation)
|
||||
val titleLink = ActionLink(title) {
|
||||
project.service<ChatToolWindowContentManager>()
|
||||
.displayConversation(conversation)
|
||||
}.apply {
|
||||
icon = AllIcons.General.Balloon
|
||||
toolTipText =
|
||||
MarkdownUtil.convertMdToHtml(
|
||||
formatConversation(conversation)
|
||||
)
|
||||
}
|
||||
add(BorderLayoutPanel().addToLeft(titleLink).andTransparent())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (referencedFilePaths.isNotEmpty()) {
|
||||
application.executeOnPooledThread {
|
||||
val links = referencedFilePaths
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ object PromptTextFieldConstants {
|
|||
"files", "file", "f",
|
||||
"folders", "folder", "fold",
|
||||
"git", "g",
|
||||
"conversations", "conversation", "conv", "c",
|
||||
"history", "hist", "h",
|
||||
"personas", "persona", "p",
|
||||
"docs", "doc", "d",
|
||||
"mcp", "m",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class SearchManager(
|
|||
FilesGroupItem(project, tagManager),
|
||||
FoldersGroupItem(project, tagManager),
|
||||
GitGroupItem(project),
|
||||
HistoryGroupItem(),
|
||||
PersonasGroupItem(tagManager),
|
||||
DocsGroupItem(tagManager),
|
||||
MCPGroupItem(),
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ import com.intellij.openapi.project.Project
|
|||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import ee.carlrobert.codegpt.EncodingManager
|
||||
import ee.carlrobert.codegpt.completions.CompletionRequestUtil
|
||||
import ee.carlrobert.codegpt.conversations.Conversation
|
||||
import ee.carlrobert.codegpt.conversations.ConversationsState
|
||||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.*
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
|
||||
import ee.carlrobert.codegpt.util.GitUtil
|
||||
import git4idea.GitCommit
|
||||
import java.util.*
|
||||
|
||||
object TagProcessorFactory {
|
||||
|
||||
|
|
@ -17,6 +21,7 @@ object TagProcessorFactory {
|
|||
return when (tagDetails) {
|
||||
is FileTagDetails -> FileTagProcessor(tagDetails)
|
||||
is SelectionTagDetails -> SelectionTagProcessor(tagDetails)
|
||||
is HistoryTagDetails -> ConversationTagProcessor(tagDetails)
|
||||
is DocumentationTagDetails -> DocumentationTagProcessor(tagDetails)
|
||||
is PersonaTagDetails -> PersonaTagProcessor(tagDetails)
|
||||
is FolderTagDetails -> FolderTagProcessor(tagDetails)
|
||||
|
|
@ -195,4 +200,42 @@ class CurrentGitChangesTagProcessor(
|
|||
project
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationTagProcessor(
|
||||
private val tagDetails: HistoryTagDetails
|
||||
) : TagProcessor {
|
||||
|
||||
companion object {
|
||||
fun getConversation(conversationId: UUID) =
|
||||
ConversationsState.getCurrentConversation()?.takeIf {
|
||||
it.id.equals(conversationId)
|
||||
} ?: ConversationsState.getInstance().conversations.find {
|
||||
it.id.equals(conversationId)
|
||||
}
|
||||
|
||||
fun formatConversation(conversation: Conversation): String {
|
||||
val stringBuilder = StringBuilder()
|
||||
stringBuilder.append(
|
||||
"# History\n\n"
|
||||
)
|
||||
stringBuilder.append(
|
||||
"## Conversation: ${HistoryActionItem.getConversationTitle(conversation)}\n\n"
|
||||
)
|
||||
|
||||
conversation.messages.forEachIndexed { index, msg ->
|
||||
stringBuilder.append("**User**: ${msg.prompt}\n\n")
|
||||
stringBuilder.append("**Assistant**: ${msg.response}\n\n")
|
||||
stringBuilder.append("\n")
|
||||
}
|
||||
return stringBuilder.toString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun process(message: Message, stringBuilder: StringBuilder) {
|
||||
if (message.conversationsHistoryIds == null) {
|
||||
message.conversationsHistoryIds = mutableListOf()
|
||||
}
|
||||
message.conversationsHistoryIds?.add(tagDetails.conversationId)
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,8 @@ sealed class TagDetails(
|
|||
}
|
||||
}
|
||||
|
||||
class EditorTagDetails(val virtualFile: VirtualFile) : TagDetails(virtualFile.name, virtualFile.fileType.icon) {
|
||||
class EditorTagDetails(val virtualFile: VirtualFile) :
|
||||
TagDetails(virtualFile.name, virtualFile.fileType.icon) {
|
||||
|
||||
private val type: String = "EditorTagDetails"
|
||||
|
||||
|
|
@ -51,7 +52,8 @@ class EditorTagDetails(val virtualFile: VirtualFile) : TagDetails(virtualFile.na
|
|||
|
||||
}
|
||||
|
||||
class FileTagDetails(val virtualFile: VirtualFile) : TagDetails(virtualFile.name, virtualFile.fileType.icon) {
|
||||
class FileTagDetails(val virtualFile: VirtualFile) :
|
||||
TagDetails(virtualFile.name, virtualFile.fileType.icon) {
|
||||
|
||||
private val type: String = "FileTagDetails"
|
||||
|
||||
|
|
@ -119,4 +121,9 @@ data class FolderTagDetails(var folder: VirtualFile) :
|
|||
|
||||
class WebTagDetails : TagDetails("Web", AllIcons.General.Web)
|
||||
|
||||
data class HistoryTagDetails(
|
||||
val conversationId: UUID,
|
||||
val title: String,
|
||||
) : TagDetails(title, AllIcons.General.Balloon)
|
||||
|
||||
class EmptyTagDetails : TagDetails("")
|
||||
|
|
@ -4,9 +4,12 @@ import com.intellij.codeInsight.lookup.LookupElement
|
|||
import com.intellij.codeInsight.lookup.LookupElementPresentation
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.AbstractLookupItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem
|
||||
import java.util.UUID
|
||||
|
||||
abstract class AbstractLookupActionItem : AbstractLookupItem(), LookupActionItem {
|
||||
|
||||
private val id: UUID = UUID.randomUUID()
|
||||
|
||||
override fun setPresentation(element: LookupElement, presentation: LookupElementPresentation) {
|
||||
presentation.icon = icon
|
||||
presentation.itemText = displayName
|
||||
|
|
@ -14,6 +17,6 @@ abstract class AbstractLookupActionItem : AbstractLookupItem(), LookupActionItem
|
|||
}
|
||||
|
||||
override fun getLookupString(): String {
|
||||
return "action_${displayName.replace(" ", "_").lowercase()}"
|
||||
return "action_${id}"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package ee.carlrobert.codegpt.ui.textarea.lookup.action
|
||||
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.conversations.Conversation
|
||||
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.HistoryTagDetails
|
||||
import javax.swing.Icon
|
||||
|
||||
class HistoryActionItem(
|
||||
private val conversation: Conversation,
|
||||
) : AbstractLookupActionItem() {
|
||||
|
||||
companion object {
|
||||
fun getConversationTitle(conversation: Conversation): String {
|
||||
return conversation.messages.firstOrNull()?.let { firstMessage ->
|
||||
firstMessage.prompt?.take(60) ?: firstMessage.response?.take(60)
|
||||
} ?: "Conversation"
|
||||
}
|
||||
}
|
||||
|
||||
override val displayName: String
|
||||
get() = getConversationTitle(conversation)
|
||||
|
||||
override val icon: Icon
|
||||
get() = AllIcons.General.Balloon
|
||||
|
||||
override fun execute(project: Project, userInputPanel: UserInputPanel) {
|
||||
userInputPanel.addTag(
|
||||
HistoryTagDetails(
|
||||
conversationId = conversation.id,
|
||||
title = displayName,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package ee.carlrobert.codegpt.ui.textarea.lookup.group
|
||||
|
||||
import com.intellij.codeInsight.lookup.impl.LookupImpl
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
import ee.carlrobert.codegpt.conversations.ConversationsState
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.DynamicLookupGroupItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupUtil
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class HistoryGroupItem : AbstractLookupGroupItem(), DynamicLookupGroupItem {
|
||||
|
||||
override val displayName: String =
|
||||
CodeGPTBundle.get("suggestionGroupItem.history.displayName")
|
||||
override val icon = AllIcons.Vcs.History
|
||||
|
||||
private val addedItems = mutableSetOf<String>()
|
||||
|
||||
override suspend fun getLookupItems(searchText: String): List<LookupActionItem> {
|
||||
return ConversationsState.getInstance().conversations
|
||||
.sortedByDescending { it.updatedOn }
|
||||
.filter { conversation ->
|
||||
if (searchText.isEmpty()) {
|
||||
true
|
||||
} else {
|
||||
val title = HistoryActionItem.getConversationTitle(conversation)
|
||||
title.contains(searchText, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
.map { HistoryActionItem(it) }
|
||||
}
|
||||
|
||||
override suspend fun updateLookupList(lookup: LookupImpl, searchText: String) {
|
||||
val filteredItems = getLookupItems(searchText)
|
||||
|
||||
withContext(Dispatchers.Default) {
|
||||
if (searchText.isEmpty()) {
|
||||
addedItems.clear()
|
||||
}
|
||||
|
||||
filteredItems.forEach { item ->
|
||||
val itemKey = item.displayName
|
||||
if (!addedItems.contains(itemKey)) {
|
||||
addedItems.add(itemKey)
|
||||
runInEdt {
|
||||
if (!lookup.isLookupDisposed) {
|
||||
LookupUtil.addLookupItem(lookup, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -310,6 +310,7 @@ userMessagePanel.persona.title=PERSONA
|
|||
suggestionGroupItem.files.displayName=Files
|
||||
suggestionGroupItem.folders.displayName=Folders
|
||||
suggestionGroupItem.personas.displayName=Personas
|
||||
suggestionGroupItem.history.displayName=History
|
||||
suggestionGroupItem.docs.displayName=Docs
|
||||
suggestionGroupItem.git.displayName=Git
|
||||
suggestionGroupItem.mcp.displayName=MCP (soon)
|
||||
|
|
@ -325,4 +326,4 @@ tagPopupMenuItem.closeAll=Close All Tags
|
|||
tagPopupMenuItem.closeTagsToLeft=Close Tags to the Left
|
||||
tagPopupMenuItem.closeTagsToRight=Close Tags to the Right
|
||||
toolwindow.chat.loading=Generating response...
|
||||
headerPanel.error.searchBlockNotMapped.title=Failed to Locate Search Block
|
||||
headerPanel.error.searchBlockNotMapped.title=Failed to Locate Search Block
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import ee.carlrobert.codegpt.settings.prompts.PromptsSettings
|
|||
import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.groups.Tuple
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import testsupport.IntegrationTest
|
||||
|
||||
class CompletionRequestProviderTest : IntegrationTest() {
|
||||
|
|
@ -29,7 +30,7 @@ class CompletionRequestProviderTest : IntegrationTest() {
|
|||
assertThat(request.messages)
|
||||
.extracting("role", "content")
|
||||
.containsExactly(
|
||||
Tuple.tuple("system", "TEST_SYSTEM_PROMPT"),
|
||||
Tuple.tuple("system", "TEST_SYSTEM_PROMPT\n"),
|
||||
Tuple.tuple("user", "TEST_PROMPT"),
|
||||
Tuple.tuple("assistant", firstMessage.response),
|
||||
Tuple.tuple("user", "TEST_PROMPT"),
|
||||
|
|
@ -55,7 +56,7 @@ class CompletionRequestProviderTest : IntegrationTest() {
|
|||
assertThat(request.messages)
|
||||
.extracting("role", "content")
|
||||
.containsExactly(
|
||||
Tuple.tuple("system", "TEST_SYSTEM_PROMPT"),
|
||||
Tuple.tuple("system", "TEST_SYSTEM_PROMPT\n"),
|
||||
Tuple.tuple("user", "FIRST_TEST_PROMPT"),
|
||||
Tuple.tuple("assistant", firstMessage.response),
|
||||
Tuple.tuple("user", "SECOND_TEST_PROMPT")
|
||||
|
|
@ -84,7 +85,6 @@ class CompletionRequestProviderTest : IntegrationTest() {
|
|||
|
||||
private fun createDummyMessage(prompt: String, tokenSize: Int): Message {
|
||||
val message = Message(prompt)
|
||||
// 'zz' = 1 token, prompt = 6 tokens, 7 tokens per message (GPT-3),
|
||||
message.response = "zz".repeat((tokenSize) - 6 - 7)
|
||||
return message
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package ee.carlrobert.codegpt.completions
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import ee.carlrobert.codegpt.completions.HuggingFaceModel
|
||||
import ee.carlrobert.codegpt.completions.llama.PromptTemplate.LLAMA
|
||||
import ee.carlrobert.codegpt.conversations.ConversationService
|
||||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
|
|
@ -34,7 +35,7 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() {
|
|||
.containsExactly(
|
||||
"gpt-4o",
|
||||
listOf(
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
|
||||
mapOf("role" to "user", "content" to "TEST_PROMPT")
|
||||
)
|
||||
)
|
||||
|
|
@ -117,7 +118,7 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() {
|
|||
.containsExactly(
|
||||
HuggingFaceModel.LLAMA_3_8B_Q6_K.code,
|
||||
listOf(
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
|
||||
mapOf("role" to "user", "content" to "TEST_PROMPT")
|
||||
)
|
||||
)
|
||||
|
|
@ -199,7 +200,7 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() {
|
|||
.containsExactly(
|
||||
"TEST_MODEL",
|
||||
listOf(
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
|
||||
mapOf("role" to "user", "content" to "TEST_PROMPT")
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() {
|
|||
.containsExactly(
|
||||
"gpt-4o",
|
||||
listOf(
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
|
||||
mapOf("role" to "user", "content" to "Hello!")
|
||||
)
|
||||
)
|
||||
|
|
@ -116,7 +116,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() {
|
|||
.containsExactly(
|
||||
"gpt-4o",
|
||||
listOf(
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
|
||||
mapOf(
|
||||
"role" to "user",
|
||||
"content" to """
|
||||
|
|
@ -211,7 +211,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() {
|
|||
.containsExactly(
|
||||
"gpt-4-vision-preview",
|
||||
listOf(
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
|
||||
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
|
||||
mapOf(
|
||||
"role" to "user", "content" to listOf(
|
||||
mapOf(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
package ee.carlrobert.codegpt.ui.textarea
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import ee.carlrobert.codegpt.conversations.ConversationService
|
||||
import ee.carlrobert.codegpt.conversations.ConversationsState
|
||||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import testsupport.IntegrationTest
|
||||
import java.util.*
|
||||
|
||||
class ConversationTagProcessorTest : IntegrationTest() {
|
||||
|
||||
private lateinit var conversationService: ConversationService
|
||||
|
||||
public override fun setUp() {
|
||||
super.setUp()
|
||||
conversationService = service<ConversationService>()
|
||||
ConversationsState.getInstance().conversations.clear()
|
||||
}
|
||||
|
||||
fun `test should format conversation with single message`() {
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message("How do I create a REST API?").apply {
|
||||
response = "You can create a REST API using frameworks like Spring Boot or Express.js."
|
||||
})
|
||||
|
||||
val formatted = ConversationTagProcessor.formatConversation(conversation)
|
||||
|
||||
assertThat(formatted).contains("# History")
|
||||
assertThat(formatted).contains("## Conversation: How do I create a REST API?")
|
||||
assertThat(formatted).contains("**User**: How do I create a REST API?")
|
||||
assertThat(formatted).contains("**Assistant**: You can create a REST API using frameworks like Spring Boot or Express.js.")
|
||||
}
|
||||
|
||||
fun `test should format conversation with multiple messages`() {
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message("What is Docker?").apply {
|
||||
response = "Docker is a containerization platform."
|
||||
})
|
||||
conversation.addMessage(Message("How do I install Docker?").apply {
|
||||
response = "You can install Docker from docker.com."
|
||||
})
|
||||
conversation.addMessage(Message("What are Docker containers?").apply {
|
||||
response = "Containers are lightweight, portable environments."
|
||||
})
|
||||
|
||||
val formatted = ConversationTagProcessor.formatConversation(conversation)
|
||||
|
||||
assertThat(formatted).contains("# History")
|
||||
assertThat(formatted).contains("## Conversation: What is Docker?")
|
||||
assertThat(formatted).contains("**User**: What is Docker?")
|
||||
assertThat(formatted).contains("**Assistant**: Docker is a containerization platform.")
|
||||
assertThat(formatted).contains("**User**: How do I install Docker?")
|
||||
assertThat(formatted).contains("**Assistant**: You can install Docker from docker.com.")
|
||||
assertThat(formatted).contains("**User**: What are Docker containers?")
|
||||
assertThat(formatted).contains("**Assistant**: Containers are lightweight, portable environments.")
|
||||
}
|
||||
|
||||
fun `test should handle null prompt in formatting`() {
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message("").apply {
|
||||
prompt = null
|
||||
response = "This is a response without a prompt."
|
||||
})
|
||||
|
||||
val formatted = ConversationTagProcessor.formatConversation(conversation)
|
||||
|
||||
assertThat(formatted).contains("# History")
|
||||
assertThat(formatted).contains("**User**: null")
|
||||
assertThat(formatted).contains("**Assistant**: This is a response without a prompt.")
|
||||
}
|
||||
|
||||
fun `test should handle null response in formatting`() {
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message("What is AI?").apply {
|
||||
response = null
|
||||
})
|
||||
|
||||
val formatted = ConversationTagProcessor.formatConversation(conversation)
|
||||
|
||||
assertThat(formatted).contains("# History")
|
||||
assertThat(formatted).contains("**User**: What is AI?")
|
||||
assertThat(formatted).contains("**Assistant**: null")
|
||||
}
|
||||
|
||||
fun `test should use first message as conversation title`() {
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message("First message").apply {
|
||||
response = "First response"
|
||||
})
|
||||
conversation.addMessage(Message("Second message").apply {
|
||||
response = "Second response"
|
||||
})
|
||||
|
||||
val formatted = ConversationTagProcessor.formatConversation(conversation)
|
||||
|
||||
assertThat(formatted).contains("## Conversation: First message")
|
||||
assertThat(formatted).doesNotContain("## Conversation: Second message")
|
||||
}
|
||||
|
||||
fun `test should find current conversation by id`() {
|
||||
val conversation = conversationService.startConversation()
|
||||
conversation.addMessage(Message("Current conversation test").apply {
|
||||
response = "This is the current conversation"
|
||||
})
|
||||
|
||||
val foundConversation = ConversationTagProcessor.getConversation(conversation.id)
|
||||
|
||||
assertThat(foundConversation).isNotNull
|
||||
assertThat(foundConversation!!.id).isEqualTo(conversation.id)
|
||||
assertThat(foundConversation.messages).hasSize(1)
|
||||
assertThat(foundConversation.messages[0].prompt).isEqualTo("Current conversation test")
|
||||
}
|
||||
|
||||
fun `test should find stored conversation by id`() {
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message("Stored conversation test").apply {
|
||||
response = "This is a stored conversation"
|
||||
})
|
||||
conversationService.addConversation(conversation)
|
||||
|
||||
val foundConversation = ConversationTagProcessor.getConversation(conversation.id)
|
||||
|
||||
assertThat(foundConversation).isNotNull
|
||||
assertThat(foundConversation!!.id).isEqualTo(conversation.id)
|
||||
assertThat(foundConversation.messages).hasSize(1)
|
||||
assertThat(foundConversation.messages[0].prompt).isEqualTo("Stored conversation test")
|
||||
}
|
||||
|
||||
fun `test should prefer current conversation over stored when same id`() {
|
||||
val storedConversation = conversationService.createConversation()
|
||||
storedConversation.addMessage(Message("Stored version").apply {
|
||||
response = "This is stored"
|
||||
})
|
||||
conversationService.addConversation(storedConversation)
|
||||
val currentConversation = conversationService.startConversation()
|
||||
currentConversation.id = storedConversation.id
|
||||
currentConversation.addMessage(Message("Current version").apply {
|
||||
response = "This is current"
|
||||
})
|
||||
|
||||
val foundConversation = ConversationTagProcessor.getConversation(storedConversation.id)
|
||||
|
||||
assertThat(foundConversation).isNotNull
|
||||
assertThat(foundConversation!!.messages[0].prompt).isEqualTo("Current version")
|
||||
}
|
||||
|
||||
fun `test should return null for non-existent conversation`() {
|
||||
val nonExistentId = UUID.randomUUID()
|
||||
|
||||
val foundConversation = ConversationTagProcessor.getConversation(nonExistentId)
|
||||
|
||||
assertThat(foundConversation).isNull()
|
||||
}
|
||||
|
||||
fun `test should integrate with history action item`() {
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message("Integration test prompt").apply {
|
||||
response = "Integration test response"
|
||||
})
|
||||
conversationService.addConversation(conversation)
|
||||
val historyActionItem = HistoryActionItem(conversation)
|
||||
val foundConversation = ConversationTagProcessor.getConversation(conversation.id)
|
||||
|
||||
val conversationTitle = historyActionItem.displayName
|
||||
val formatted = ConversationTagProcessor.formatConversation(foundConversation!!)
|
||||
|
||||
assertThat(conversationTitle).isEqualTo("Integration test prompt")
|
||||
assertThat(formatted).contains("## Conversation: Integration test prompt")
|
||||
assertThat(formatted).contains("**User**: Integration test prompt")
|
||||
assertThat(formatted).contains("**Assistant**: Integration test response")
|
||||
}
|
||||
|
||||
fun `test should handle empty message list`() {
|
||||
val conversation = conversationService.createConversation()
|
||||
|
||||
val formatted = ConversationTagProcessor.formatConversation(conversation)
|
||||
|
||||
assertThat(formatted).contains("# History")
|
||||
assertThat(formatted).contains("## Conversation: Conversation")
|
||||
}
|
||||
|
||||
fun `test should handle long messages and truncate title`() {
|
||||
val conversation = conversationService.createConversation()
|
||||
val longPrompt =
|
||||
"This is a very long prompt that contains a lot of text to test how the conversation formatter handles lengthy content. " +
|
||||
"It should include all the text without truncating it, unlike the title which gets truncated to 60 characters."
|
||||
val longResponse =
|
||||
"This is an equally long response that provides detailed information about the question asked. " +
|
||||
"The response should also be included in full without any truncation when formatting the conversation history."
|
||||
conversation.addMessage(Message(longPrompt).apply {
|
||||
response = longResponse
|
||||
})
|
||||
|
||||
val formatted = ConversationTagProcessor.formatConversation(conversation)
|
||||
|
||||
assertThat(formatted).contains("# History")
|
||||
assertThat(formatted).contains("**User**: $longPrompt")
|
||||
assertThat(formatted).contains("**Assistant**: $longResponse")
|
||||
val titleInFormatted = formatted.substringAfter("## Conversation: ").substringBefore("\n")
|
||||
assertThat(titleInFormatted).hasSize(60)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
package ee.carlrobert.codegpt.ui.textarea
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import ee.carlrobert.codegpt.conversations.ConversationService
|
||||
import ee.carlrobert.codegpt.conversations.ConversationsState
|
||||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.group.HistoryGroupItem
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import testsupport.IntegrationTest
|
||||
|
||||
class HistorySearchIntegrationTest : IntegrationTest() {
|
||||
|
||||
private lateinit var conversationService: ConversationService
|
||||
private lateinit var searchManager: SearchManager
|
||||
private lateinit var historyGroupItem: HistoryGroupItem
|
||||
|
||||
public override fun setUp() {
|
||||
super.setUp()
|
||||
conversationService = service<ConversationService>()
|
||||
ConversationsState.getInstance().conversations.clear()
|
||||
|
||||
val tagManager = TagManager(testRootDisposable)
|
||||
searchManager = SearchManager(project, tagManager)
|
||||
historyGroupItem = HistoryGroupItem()
|
||||
}
|
||||
|
||||
fun `test should include history group in search manager default groups`() {
|
||||
val defaultGroups = searchManager.getDefaultGroups()
|
||||
|
||||
assertThat(defaultGroups).anyMatch { it is HistoryGroupItem }
|
||||
}
|
||||
|
||||
fun `test should include history results in global search`() {
|
||||
createTestConversations()
|
||||
|
||||
val results = runBlocking { searchManager.performGlobalSearch("java") }
|
||||
|
||||
val historyResults = results.filterIsInstance<HistoryActionItem>()
|
||||
assertThat(historyResults).hasSizeGreaterThan(0)
|
||||
assertThat(historyResults.any { it.displayName.contains("Java", ignoreCase = true) }).isTrue()
|
||||
}
|
||||
|
||||
fun `test should filter conversations by search terms`() {
|
||||
ConversationsState.getInstance().conversations.clear()
|
||||
createTestConversations()
|
||||
val testCases = mapOf(
|
||||
"java" to 3,
|
||||
"python" to 1,
|
||||
"javascript" to 2,
|
||||
"programming" to 1,
|
||||
"database" to 1,
|
||||
"nonexistent" to 0
|
||||
)
|
||||
|
||||
testCases.forEach { (searchTerm, expectedCount) ->
|
||||
val results = runBlocking { historyGroupItem.getLookupItems(searchTerm) }
|
||||
|
||||
assertThat(results)
|
||||
.withFailMessage("Search term '$searchTerm' should return $expectedCount results")
|
||||
.hasSize(expectedCount)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test should return all matching conversations for search term`() {
|
||||
val conversations = listOf(
|
||||
"Java programming basics" to "Learn Java fundamentals",
|
||||
"Advanced Java concepts" to "Deep dive into Java",
|
||||
"JavaScript vs Java comparison" to "Compare languages",
|
||||
"Python programming" to "Learn Python basics"
|
||||
)
|
||||
conversations.forEach { (prompt, response) ->
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message(prompt).apply { this.response = response })
|
||||
conversationService.addConversation(conversation)
|
||||
}
|
||||
|
||||
val javaResults = runBlocking { historyGroupItem.getLookupItems("java") }
|
||||
|
||||
assertThat(javaResults).hasSize(3)
|
||||
val displayNames = javaResults.map { (it as HistoryActionItem).displayName }
|
||||
displayNames.forEach { name ->
|
||||
assertThat(name.lowercase()).contains("java")
|
||||
}
|
||||
}
|
||||
|
||||
fun `test should handle special characters in search`() {
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message("What is C++ programming?").apply {
|
||||
response = "C++ is a powerful programming language"
|
||||
})
|
||||
conversationService.addConversation(conversation)
|
||||
|
||||
val results = runBlocking { historyGroupItem.getLookupItems("c++") }
|
||||
|
||||
assertThat(results).hasSize(1)
|
||||
assertThat((results[0] as HistoryActionItem).displayName).contains("C++")
|
||||
}
|
||||
|
||||
fun `test should perform search efficiently with many conversations`() {
|
||||
for (i in 1..100) {
|
||||
val conversation = conversationService.createConversation()
|
||||
val topic = when (i % 5) {
|
||||
0 -> "Java"
|
||||
1 -> "Python"
|
||||
2 -> "JavaScript"
|
||||
3 -> "Database"
|
||||
else -> "General"
|
||||
}
|
||||
conversation.addMessage(Message("Question about $topic #$i").apply {
|
||||
response = "Answer about $topic #$i"
|
||||
})
|
||||
conversationService.addConversation(conversation)
|
||||
}
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val results = runBlocking { historyGroupItem.getLookupItems("java") }
|
||||
val endTime = System.currentTimeMillis()
|
||||
|
||||
assertThat(results).hasSize(40)
|
||||
assertThat(endTime - startTime).isLessThan(1000)
|
||||
results.forEach { result ->
|
||||
val displayName = (result as HistoryActionItem).displayName
|
||||
assertThat(displayName.lowercase()).contains("java")
|
||||
}
|
||||
}
|
||||
|
||||
fun `test should handle empty and whitespace queries`() {
|
||||
createTestConversations()
|
||||
|
||||
val emptyResults = runBlocking { historyGroupItem.getLookupItems("") }
|
||||
val whitespaceResults = runBlocking { historyGroupItem.getLookupItems(" ") }
|
||||
|
||||
assertThat(emptyResults).hasSizeLessThanOrEqualTo(10)
|
||||
assertThat(whitespaceResults).hasSizeLessThanOrEqualTo(emptyResults.size)
|
||||
}
|
||||
|
||||
fun `test should search in conversation titles`() {
|
||||
val conversation1 = conversationService.createConversation()
|
||||
conversation1.addMessage(Message("How to use Docker containers?").apply {
|
||||
response = "Docker containers are lightweight virtualization"
|
||||
})
|
||||
conversationService.addConversation(conversation1)
|
||||
val conversation2 = conversationService.createConversation()
|
||||
conversation2.addMessage(Message("What is virtualization?").apply {
|
||||
response = "Virtualization allows running multiple Docker instances"
|
||||
})
|
||||
conversationService.addConversation(conversation2)
|
||||
|
||||
val dockerResults = runBlocking { historyGroupItem.getLookupItems("docker") }
|
||||
val virtualizationResults = runBlocking { historyGroupItem.getLookupItems("virtualization") }
|
||||
|
||||
assertThat(dockerResults).hasSize(1)
|
||||
assertThat((dockerResults[0] as HistoryActionItem).displayName).contains("Docker")
|
||||
assertThat(virtualizationResults).hasSize(1)
|
||||
assertThat((virtualizationResults[0] as HistoryActionItem).displayName).contains("virtualization")
|
||||
}
|
||||
|
||||
fun `test should match history aliases in search manager`() {
|
||||
val historyAliases = listOf("history", "hist", "h")
|
||||
|
||||
historyAliases.forEach { alias ->
|
||||
val matches = searchManager.matchesAnyDefaultGroup(alias)
|
||||
assertThat(matches)
|
||||
.withFailMessage("Alias '$alias' should match default group names")
|
||||
.isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
fun `test should have correct properties for history group item`() {
|
||||
assertThat(historyGroupItem.enabled).isTrue()
|
||||
assertThat(historyGroupItem.displayName).isEqualTo("History")
|
||||
assertThat(historyGroupItem.icon).isNotNull()
|
||||
}
|
||||
|
||||
private fun createTestConversations() {
|
||||
val testData = listOf(
|
||||
"How to write Java code?" to "Use Java syntax and compile with javac",
|
||||
"Python vs JavaScript comparison" to "Python is interpreted, JavaScript runs in browsers",
|
||||
"What is database normalization?" to "Database normalization reduces redundancy",
|
||||
"Best practices for programming" to "Write clean, readable, and testable code",
|
||||
"JavaScript async/await tutorial" to "Use async/await for asynchronous programming"
|
||||
)
|
||||
|
||||
testData.forEach { (prompt, response) ->
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message(prompt).apply { this.response = response })
|
||||
conversationService.addConversation(conversation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
package ee.carlrobert.codegpt.ui.textarea
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import ee.carlrobert.codegpt.conversations.ConversationService
|
||||
import ee.carlrobert.codegpt.conversations.ConversationsState
|
||||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.MessageBuilder
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.HistoryTagDetails
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
|
||||
import ee.carlrobert.codegpt.ui.textarea.lookup.group.HistoryGroupItem
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import testsupport.IntegrationTest
|
||||
import java.time.LocalDateTime
|
||||
import java.util.*
|
||||
|
||||
class HistoryTagIntegrationTest : IntegrationTest() {
|
||||
|
||||
private lateinit var conversationService: ConversationService
|
||||
|
||||
public override fun setUp() {
|
||||
super.setUp()
|
||||
conversationService = service<ConversationService>()
|
||||
// Clear any existing conversations
|
||||
ConversationsState.getInstance().conversations.clear()
|
||||
}
|
||||
|
||||
fun testShouldDisplayCorrectNameForHistoryActionItem() {
|
||||
val conversation = conversationService.createConversation()
|
||||
val message = Message("What is the capital of France?")
|
||||
message.response = "The capital of France is Paris."
|
||||
conversation.addMessage(message)
|
||||
|
||||
val historyActionItem = HistoryActionItem(conversation)
|
||||
|
||||
assertThat(historyActionItem.displayName).isEqualTo("What is the capital of France?")
|
||||
}
|
||||
|
||||
fun testShouldTruncateLongPromptForDisplayName() {
|
||||
val longPrompt = "This is a very long prompt that should be truncated when used as a conversation title because it exceeds the 60 character limit"
|
||||
val conversation = conversationService.createConversation()
|
||||
val message = Message(longPrompt)
|
||||
message.response = "This is a response."
|
||||
conversation.addMessage(message)
|
||||
|
||||
val historyActionItem = HistoryActionItem(conversation)
|
||||
|
||||
assertThat(historyActionItem.displayName).hasSize(60)
|
||||
assertThat(historyActionItem.displayName).startsWith("This is a very long prompt that should be truncated when")
|
||||
}
|
||||
|
||||
fun testShouldFallbackToResponseWhenPromptIsNull() {
|
||||
val conversation = conversationService.createConversation()
|
||||
val message = Message("")
|
||||
message.prompt = null
|
||||
message.response = "This is only a response without a prompt."
|
||||
conversation.addMessage(message)
|
||||
|
||||
val historyActionItem = HistoryActionItem(conversation)
|
||||
|
||||
assertThat(historyActionItem.displayName).isEqualTo("This is only a response without a prompt.")
|
||||
}
|
||||
|
||||
fun testShouldUseDefaultTitleWhenBothPromptAndResponseAreNull() {
|
||||
val conversation = conversationService.createConversation()
|
||||
val message = Message("")
|
||||
message.prompt = null
|
||||
message.response = null
|
||||
conversation.addMessage(message)
|
||||
|
||||
val historyActionItem = HistoryActionItem(conversation)
|
||||
|
||||
assertThat(historyActionItem.displayName).isEqualTo("Conversation")
|
||||
}
|
||||
|
||||
fun testShouldCreateCorrectTagDetails() {
|
||||
val conversation = conversationService.createConversation()
|
||||
val message = Message("What is the capital of France?")
|
||||
message.response = "The capital of France is Paris."
|
||||
conversation.addMessage(message)
|
||||
val historyActionItem = HistoryActionItem(conversation)
|
||||
|
||||
val expectedTag = HistoryTagDetails(conversation.id, historyActionItem.displayName)
|
||||
|
||||
assertThat(expectedTag.conversationId).isEqualTo(conversation.id)
|
||||
assertThat(expectedTag.title).isEqualTo("What is the capital of France?")
|
||||
assertThat(historyActionItem.icon).isNotNull()
|
||||
}
|
||||
|
||||
fun testShouldFilterConversationsBySearchText() {
|
||||
val historyGroupItem = HistoryGroupItem()
|
||||
val javaConversation = conversationService.createConversation()
|
||||
javaConversation.addMessage(Message("How to write Java code?").apply { response = "Use Java syntax" })
|
||||
conversationService.addConversation(javaConversation)
|
||||
val pythonConversation = conversationService.createConversation()
|
||||
pythonConversation.addMessage(Message("Python programming tutorial").apply { response = "Learn Python basics" })
|
||||
conversationService.addConversation(pythonConversation)
|
||||
|
||||
val javaResults = runBlocking { historyGroupItem.getLookupItems("java") }
|
||||
val pythonResults = runBlocking { historyGroupItem.getLookupItems("python") }
|
||||
|
||||
assertThat(javaResults).hasSize(1)
|
||||
assertThat((javaResults[0] as HistoryActionItem).displayName).contains("Java")
|
||||
assertThat(pythonResults).hasSize(1)
|
||||
assertThat((pythonResults[0] as HistoryActionItem).displayName).contains("Python")
|
||||
}
|
||||
|
||||
fun testShouldHandleEmptyConversationsList() {
|
||||
val historyGroupItem = HistoryGroupItem()
|
||||
|
||||
val results = runBlocking { historyGroupItem.getLookupItems("") }
|
||||
|
||||
assertThat(results).isEmpty()
|
||||
}
|
||||
|
||||
fun testShouldPerformCaseInsensitiveSearch() {
|
||||
val historyGroupItem = HistoryGroupItem()
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message("JavaScript Tutorial").apply { response = "Learn JS" })
|
||||
conversationService.addConversation(conversation)
|
||||
|
||||
val upperCaseResults = runBlocking { historyGroupItem.getLookupItems("JAVASCRIPT") }
|
||||
val lowerCaseResults = runBlocking { historyGroupItem.getLookupItems("javascript") }
|
||||
val mixedCaseResults = runBlocking { historyGroupItem.getLookupItems("JavaScript") }
|
||||
|
||||
assertThat(upperCaseResults).hasSize(1)
|
||||
assertThat(lowerCaseResults).hasSize(1)
|
||||
assertThat(mixedCaseResults).hasSize(1)
|
||||
}
|
||||
|
||||
fun testShouldIntegrateHistoryTagsInMessageBuilder() {
|
||||
val existingConversation = conversationService.createConversation()
|
||||
val existingMessage = Message("Previous question")
|
||||
existingMessage.response = "Previous answer"
|
||||
existingConversation.addMessage(existingMessage)
|
||||
val historyTag = HistoryTagDetails(
|
||||
conversationId = existingConversation.id,
|
||||
title = "Previous conversation"
|
||||
)
|
||||
|
||||
val message = MessageBuilder(project, "New question referencing history")
|
||||
.withInlays(listOf(historyTag))
|
||||
.build()
|
||||
|
||||
assertThat(message.prompt).isEqualTo("New question referencing history")
|
||||
}
|
||||
|
||||
fun testShouldFormatConversationCorrectly() {
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message("First question").apply { response = "First answer" })
|
||||
conversation.addMessage(Message("Second question").apply { response = "Second answer" })
|
||||
|
||||
val formatted = ConversationTagProcessor.formatConversation(conversation)
|
||||
|
||||
assertThat(formatted).contains("# History")
|
||||
assertThat(formatted).contains("## Conversation: First question")
|
||||
assertThat(formatted).contains("**User**: First question")
|
||||
assertThat(formatted).contains("**Assistant**: First answer")
|
||||
assertThat(formatted).contains("**User**: Second question")
|
||||
assertThat(formatted).contains("**Assistant**: Second answer")
|
||||
}
|
||||
|
||||
fun testShouldFindConversationById() {
|
||||
val conversation = conversationService.createConversation()
|
||||
conversation.addMessage(Message("Test question").apply { response = "Test answer" })
|
||||
conversationService.addConversation(conversation)
|
||||
|
||||
val foundConversation = ConversationTagProcessor.getConversation(conversation.id)
|
||||
|
||||
assertThat(foundConversation).isNotNull
|
||||
assertThat(foundConversation!!.id).isEqualTo(conversation.id)
|
||||
assertThat(foundConversation.messages).hasSize(1)
|
||||
assertThat(foundConversation.messages[0].prompt).isEqualTo("Test question")
|
||||
}
|
||||
|
||||
fun testShouldReturnNullForNonExistentConversation() {
|
||||
val nonExistentId = UUID.randomUUID()
|
||||
|
||||
val foundConversation = ConversationTagProcessor.getConversation(nonExistentId)
|
||||
|
||||
assertThat(foundConversation).isNull()
|
||||
}
|
||||
|
||||
fun testShouldIncludeHistoryGroupInSearchManager() {
|
||||
val tagManager = TagManager(testRootDisposable)
|
||||
val searchManager = SearchManager(project, tagManager)
|
||||
|
||||
val defaultGroups = searchManager.getDefaultGroups()
|
||||
|
||||
assertThat(defaultGroups).anyMatch { it is HistoryGroupItem }
|
||||
}
|
||||
|
||||
fun testShouldIncludeHistoryAliasesInConstants() {
|
||||
assertThat(PromptTextFieldConstants.DEFAULT_GROUP_NAMES)
|
||||
.contains("history", "hist", "h")
|
||||
}
|
||||
|
||||
fun testShouldImplementEqualsAndHashcodeCorrectlyForHistoryTagDetails() {
|
||||
val conversationId = UUID.randomUUID()
|
||||
val tag1 = HistoryTagDetails(conversationId, "Test conversation")
|
||||
val tag2 = HistoryTagDetails(conversationId, "Test conversation")
|
||||
val tag3 = HistoryTagDetails(UUID.randomUUID(), "Different conversation")
|
||||
|
||||
assertThat(tag1).isEqualTo(tag2)
|
||||
assertThat(tag1).isNotEqualTo(tag3)
|
||||
assertThat(tag1.hashCode()).isEqualTo(tag2.hashCode())
|
||||
assertThat(tag1.hashCode()).isNotEqualTo(tag3.hashCode())
|
||||
}
|
||||
|
||||
fun testShouldSortConversationsByUpdatedDateDescending() {
|
||||
val historyGroupItem = HistoryGroupItem()
|
||||
val now = LocalDateTime.now()
|
||||
val oldConversation = conversationService.createConversation()
|
||||
oldConversation.updatedOn = now.minusDays(3)
|
||||
oldConversation.addMessage(Message("Old conversation").apply { response = "Old response" })
|
||||
conversationService.addConversation(oldConversation)
|
||||
val newestConversation = conversationService.createConversation()
|
||||
newestConversation.updatedOn = now
|
||||
newestConversation.addMessage(Message("Newest conversation").apply { response = "Newest response" })
|
||||
conversationService.addConversation(newestConversation)
|
||||
val middleConversation = conversationService.createConversation()
|
||||
middleConversation.updatedOn = now.minusDays(1)
|
||||
middleConversation.addMessage(Message("Middle conversation").apply { response = "Middle response" })
|
||||
conversationService.addConversation(middleConversation)
|
||||
|
||||
val results = runBlocking { historyGroupItem.getLookupItems("") }
|
||||
|
||||
assertThat(results).hasSize(3)
|
||||
val displayNames = results.map { (it as HistoryActionItem).displayName }
|
||||
assertThat(displayNames[0]).isEqualTo("Newest conversation")
|
||||
assertThat(displayNames[1]).isEqualTo("Middle conversation")
|
||||
assertThat(displayNames[2]).isEqualTo("Old conversation")
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue