mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-05-19 07:54:46 +00:00
feat: improve chat toolwindow UI and streaming/diff handling
This commit is contained in:
parent
b00593b9bd
commit
65b1ca496e
5 changed files with 131 additions and 73 deletions
|
|
@ -10,7 +10,9 @@ import com.intellij.openapi.diagnostic.Logger;
|
|||
import com.intellij.openapi.editor.SelectionModel;
|
||||
import com.intellij.openapi.project.Project;
|
||||
import com.intellij.openapi.vfs.VirtualFile;
|
||||
import com.intellij.ui.AnimatedIcon;
|
||||
import com.intellij.ui.JBColor;
|
||||
import com.intellij.ui.components.JBLabel;
|
||||
import com.intellij.util.concurrency.AppExecutorUtil;
|
||||
import com.intellij.util.ui.JBUI;
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys;
|
||||
|
|
@ -39,7 +41,6 @@ import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction;
|
|||
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureState;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.header.LoadingPanel;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatToolWindowScrollablePanel;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensDetails;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel;
|
||||
|
|
@ -62,6 +63,8 @@ import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers;
|
|||
import ee.carlrobert.llm.client.openai.completion.ErrorDetails;
|
||||
import git4idea.GitCommit;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
|
@ -95,7 +98,7 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
private final TagManager tagManager;
|
||||
private final JPanel mcpApprovalContainer;
|
||||
private @Nullable ToolwindowChatCompletionRequestHandler requestHandler;
|
||||
private LoadingPanel inputLoadingPanel;
|
||||
private JBLabel loadingLabel;
|
||||
private final JPanel queuedMessageContainer;
|
||||
|
||||
public ChatToolWindowTabPanel(@NotNull Project project, @NotNull Conversation conversation) {
|
||||
|
|
@ -139,6 +142,9 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
queuedMessageContainer.setBorder(JBUI.Borders.empty());
|
||||
queuedMessageContainer.setOpaque(false);
|
||||
|
||||
loadingLabel = new JBLabel("", new AnimatedIcon.Default(), JBLabel.LEFT);
|
||||
loadingLabel.setVisible(false);
|
||||
|
||||
rootPanel = createRootPanel();
|
||||
|
||||
if (conversation.getMessages().isEmpty()) {
|
||||
|
|
@ -224,8 +230,9 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
|
||||
private void updateUserPromptPanel() {
|
||||
var userPromptPanel = createUserPromptPanel();
|
||||
|
||||
rootPanel.remove(rootPanel.getComponent(rootPanel.getComponentCount() - 1));
|
||||
rootPanel.add(userPromptPanel, BorderLayout.SOUTH);
|
||||
rootPanel.add(createSouthPanel(userPromptPanel), BorderLayout.SOUTH);
|
||||
rootPanel.revalidate();
|
||||
rootPanel.repaint();
|
||||
}
|
||||
|
|
@ -603,11 +610,6 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
topContainer.setLayout(new BoxLayout(topContainer, BoxLayout.Y_AXIS));
|
||||
topContainer.setOpaque(false);
|
||||
|
||||
inputLoadingPanel = new LoadingPanel(CodeGPTBundle.get("toolwindow.chat.loading"), null, null);
|
||||
inputLoadingPanel.setAlignmentX(JComponent.LEFT_ALIGNMENT);
|
||||
inputLoadingPanel.setVisible(false);
|
||||
topContainer.add(inputLoadingPanel);
|
||||
|
||||
if (queuedMessageContainer.getComponentCount() > 0) {
|
||||
queuedMessageContainer.setAlignmentX(JComponent.LEFT_ALIGNMENT);
|
||||
topContainer.add(queuedMessageContainer);
|
||||
|
|
@ -618,30 +620,59 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
topContainer.add(mcpApprovalContainer);
|
||||
}
|
||||
|
||||
var tokenPanelWrapper = JBUI.Panels.simplePanel(totalTokensPanel)
|
||||
.withBorder(JBUI.Borders.empty(4, 0, 4, 0));
|
||||
tokenPanelWrapper.setAlignmentX(JComponent.LEFT_ALIGNMENT);
|
||||
topContainer.add(tokenPanelWrapper);
|
||||
|
||||
panel.add(topContainer, BorderLayout.NORTH);
|
||||
panel.add(userInputPanel, BorderLayout.CENTER);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private JComponent createStatusPanel() {
|
||||
var statusPanel = new JPanel(new GridBagLayout());
|
||||
statusPanel.setBorder(JBUI.Borders.empty(8));
|
||||
statusPanel.setOpaque(false);
|
||||
|
||||
var gbc = new GridBagConstraints();
|
||||
gbc.gridx = 0;
|
||||
gbc.gridy = 0;
|
||||
gbc.anchor = GridBagConstraints.WEST;
|
||||
gbc.weightx = 0;
|
||||
gbc.fill = GridBagConstraints.NONE;
|
||||
statusPanel.add(loadingLabel, gbc);
|
||||
|
||||
gbc.gridx = 1;
|
||||
gbc.weightx = 1;
|
||||
gbc.fill = GridBagConstraints.HORIZONTAL;
|
||||
statusPanel.add(Box.createHorizontalGlue(), gbc);
|
||||
|
||||
gbc.gridx = 2;
|
||||
gbc.weightx = 0;
|
||||
gbc.anchor = GridBagConstraints.EAST;
|
||||
gbc.fill = GridBagConstraints.NONE;
|
||||
statusPanel.add(totalTokensPanel, gbc);
|
||||
|
||||
return statusPanel;
|
||||
}
|
||||
|
||||
private JComponent createSouthPanel(JComponent userPromptPanel) {
|
||||
var southPanel = new JPanel(new BorderLayout());
|
||||
southPanel.add(createStatusPanel(), BorderLayout.NORTH);
|
||||
southPanel.add(userPromptPanel, BorderLayout.CENTER);
|
||||
return southPanel;
|
||||
}
|
||||
|
||||
private void showInputLoading(String text) {
|
||||
if (inputLoadingPanel != null) {
|
||||
inputLoadingPanel.setText(text);
|
||||
inputLoadingPanel.setVisible(true);
|
||||
inputLoadingPanel.revalidate();
|
||||
inputLoadingPanel.repaint();
|
||||
if (loadingLabel != null) {
|
||||
loadingLabel.setText(text);
|
||||
loadingLabel.setVisible(true);
|
||||
rootPanel.revalidate();
|
||||
rootPanel.repaint();
|
||||
}
|
||||
}
|
||||
|
||||
private void hideInputLoading() {
|
||||
if (inputLoadingPanel != null) {
|
||||
inputLoadingPanel.setVisible(false);
|
||||
inputLoadingPanel.revalidate();
|
||||
inputLoadingPanel.repaint();
|
||||
if (loadingLabel != null) {
|
||||
loadingLabel.setVisible(false);
|
||||
rootPanel.revalidate();
|
||||
rootPanel.repaint();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -703,7 +734,7 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
var rootPanel = new JPanel(new BorderLayout());
|
||||
rootPanel.add(createScrollPaneWithSmartScroller(toolWindowScrollablePanel),
|
||||
BorderLayout.CENTER);
|
||||
rootPanel.add(createUserPromptPanel(), BorderLayout.SOUTH);
|
||||
rootPanel.add(createSouthPanel(createUserPromptPanel()), BorderLayout.SOUTH);
|
||||
return rootPanel;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ public class ProxyAIChoice(
|
|||
public class ProxyAIStreamChoice(
|
||||
public val finishReason: String? = null,
|
||||
public val nativeFinishReason: String? = null,
|
||||
public val delta: ProxyAIStreamDelta,
|
||||
public val delta: ProxyAIStreamDelta?,
|
||||
public val error: ProxyAIErrorResponse? = null
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -130,8 +130,8 @@ public class ProxyAILLMClient(
|
|||
|
||||
override suspend fun StreamFrameFlowBuilder.processStreamingChunk(chunk: ProxyAIChatCompletionStreamResponse) {
|
||||
chunk.choices.firstOrNull()?.let { choice ->
|
||||
choice.delta.content?.let { emitAppend(it) }
|
||||
choice.delta.toolCalls?.forEachIndexed { index, toolCall ->
|
||||
choice.delta?.content?.let { emitAppend(it) }
|
||||
choice.delta?.toolCalls?.forEachIndexed { index, toolCall ->
|
||||
val id = toolCall.id
|
||||
val name = toolCall.function.name
|
||||
val arguments = toolCall.function.arguments
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.chat.editor.diff
|
||||
|
||||
import com.intellij.diff.util.Side
|
||||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.application.ReadAction
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.application.runUndoTransparentWriteAction
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.editor.event.DocumentEvent
|
||||
import com.intellij.openapi.editor.event.DocumentListener
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
|
|
@ -11,6 +14,7 @@ import com.intellij.openapi.fileEditor.FileDocumentManager
|
|||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.util.application
|
||||
import com.intellij.util.concurrency.AppExecutorUtil
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel.Companion.RESPONSE_EDITOR_DIFF_VIEWER_KEY
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel.Companion.RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
|
@ -25,48 +29,41 @@ object DiffSyncManager {
|
|||
(set ?: mutableSetOf()).apply { add(editor) }
|
||||
}
|
||||
|
||||
if (!fileToListener.containsKey(filePath)) {
|
||||
val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath) ?: return
|
||||
val document =
|
||||
runReadAction { FileDocumentManager.getInstance().getDocument(virtualFile) }
|
||||
?: return
|
||||
val listener = object : DocumentListener {
|
||||
override fun documentChanged(event: DocumentEvent) {
|
||||
application.executeOnPooledThread {
|
||||
val affectedEditors = fileToEditors[filePath] ?: emptyList()
|
||||
for (editor in affectedEditors) {
|
||||
val diffViewer = RESPONSE_EDITOR_DIFF_VIEWER_KEY.get(editor)
|
||||
if (diffViewer != null) {
|
||||
val leftSideDoc =
|
||||
runReadAction { diffViewer.getDocument(Side.LEFT) }
|
||||
val rightSideDoc =
|
||||
runReadAction { diffViewer.getDocument(Side.RIGHT) }
|
||||
|
||||
val listener = object : DocumentListener {
|
||||
override fun documentChanged(event: DocumentEvent) {
|
||||
application.executeOnPooledThread {
|
||||
val affectedEditors = fileToEditors[filePath] ?: emptyList()
|
||||
for (editor in affectedEditors) {
|
||||
val diffViewer = RESPONSE_EDITOR_DIFF_VIEWER_KEY.get(editor)
|
||||
if (diffViewer != null) {
|
||||
val leftSideDoc =
|
||||
runReadAction { diffViewer.getDocument(Side.LEFT) }
|
||||
val rightSideDoc =
|
||||
runReadAction { diffViewer.getDocument(Side.RIGHT) }
|
||||
if (leftSideDoc.text == rightSideDoc.text) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (leftSideDoc.text == rightSideDoc.text) {
|
||||
continue
|
||||
}
|
||||
|
||||
val entry = RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY.get(editor)
|
||||
if (entry != null) {
|
||||
val (search, replace) = entry
|
||||
val newText = event.document.text
|
||||
if (!newText.contains(replace.trim())) {
|
||||
val replacedText =
|
||||
newText.replace(search.trim(), replace.trim())
|
||||
runInEdt {
|
||||
if (replacedText.length != newText.length) {
|
||||
runUndoTransparentWriteAction {
|
||||
rightSideDoc.setText(
|
||||
StringUtil.convertLineSeparators(
|
||||
replacedText
|
||||
)
|
||||
val entry = RESPONSE_EDITOR_DIFF_VIEWER_VALUE_PAIR_KEY.get(editor)
|
||||
if (entry != null) {
|
||||
val (search, replace) = entry
|
||||
val newText = event.document.text
|
||||
if (!newText.contains(replace.trim())) {
|
||||
val replacedText =
|
||||
newText.replace(search.trim(), replace.trim())
|
||||
runInEdt {
|
||||
if (replacedText.length != newText.length) {
|
||||
runUndoTransparentWriteAction {
|
||||
rightSideDoc.setText(
|
||||
StringUtil.convertLineSeparators(
|
||||
replacedText
|
||||
)
|
||||
diffViewer.scheduleRediff()
|
||||
}
|
||||
)
|
||||
diffViewer.scheduleRediff()
|
||||
}
|
||||
diffViewer.rediff(true)
|
||||
}
|
||||
diffViewer.rediff(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -74,10 +71,32 @@ object DiffSyncManager {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val existing = fileToListener.putIfAbsent(filePath, listener)
|
||||
if (existing != null) {
|
||||
return
|
||||
}
|
||||
|
||||
ReadAction.nonBlocking<Document?> {
|
||||
val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath)
|
||||
if (virtualFile == null) {
|
||||
null
|
||||
} else {
|
||||
FileDocumentManager.getInstance().getDocument(virtualFile)
|
||||
}
|
||||
}.finishOnUiThread(ModalityState.any()) { document ->
|
||||
if (document == null || fileToEditors[filePath].isNullOrEmpty()) {
|
||||
fileToListener.remove(filePath, listener)
|
||||
return@finishOnUiThread
|
||||
}
|
||||
|
||||
if (fileToListener[filePath] != listener) {
|
||||
return@finishOnUiThread
|
||||
}
|
||||
|
||||
document.addDocumentListener(listener)
|
||||
fileToListener[filePath] = listener
|
||||
}
|
||||
}.submit(AppExecutorUtil.getAppExecutorService())
|
||||
}
|
||||
|
||||
fun unregisterEditor(filePath: String, editor: EditorEx) {
|
||||
|
|
@ -85,15 +104,23 @@ object DiffSyncManager {
|
|||
set.remove(editor)
|
||||
if (set.isEmpty()) {
|
||||
fileToEditors.remove(filePath)
|
||||
|
||||
val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath)
|
||||
val document =
|
||||
virtualFile?.let { FileDocumentManager.getInstance().getDocument(it) }
|
||||
val listener = fileToListener.remove(filePath)
|
||||
if (document != null && listener != null) {
|
||||
document.removeDocumentListener(listener)
|
||||
|
||||
if (listener != null) {
|
||||
ReadAction.nonBlocking<Document?> {
|
||||
val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath)
|
||||
if (virtualFile == null) {
|
||||
null
|
||||
} else {
|
||||
FileDocumentManager.getInstance().getDocument(virtualFile)
|
||||
}
|
||||
}.finishOnUiThread(ModalityState.any()) { document ->
|
||||
if (document != null) {
|
||||
document.removeDocumentListener(listener)
|
||||
}
|
||||
}.submit(AppExecutorUtil.getAppExecutorService())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class TotalTokensPanel(
|
|||
}
|
||||
}
|
||||
|
||||
border = JBUI.Borders.empty(4)
|
||||
border = JBUI.Borders.empty()
|
||||
isOpaque = false
|
||||
add(JBLabel(AllIcons.General.ContextHelp).apply {
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue