Skip to content

Commit 4222477

Browse files
committed
feat(gui): add chat history persistence and view #364
- Add ChatHistoryService with Xodus database storage - Add ViewHistoryAction to browse and restore sessions - Integrate auto-save on new sketch creation - Add displayMessages method to SketchToolWindow - Include history icon and localization strings
1 parent c78ab07 commit 4222477

File tree

11 files changed

+358
-45
lines changed

11 files changed

+358
-45
lines changed

build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,11 @@ project(":core") {
410410
testFramework(TestFrameworkType.Platform)
411411
}
412412

413+
implementation("org.jetbrains.xodus:xodus-openAPI:2.0.1")
414+
implementation("org.jetbrains.xodus:xodus-environment:2.0.1")
415+
implementation("org.jetbrains.xodus:xodus-entity-store:2.0.1")
416+
implementation("org.jetbrains.xodus:xodus-vfs:2.0.1")
417+
413418
implementation("io.modelcontextprotocol:kotlin-sdk:0.5.0")
414419

415420
implementation("io.reactivex.rxjava3:rxjava:3.1.10")

core/src/main/kotlin/cc/unitmesh/devti/AutoDevIcons.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,7 @@ object AutoDevIcons {
112112

113113
@JvmField
114114
val VARIABLE: Icon = IconLoader.getIcon("/icons/variable.svg", AutoDevIcons::class.java)
115+
116+
@JvmField
117+
val HISTORY: Icon = IconLoader.getIcon("/icons/history.svg", AutoDevIcons::class.java)
115118
}
Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,102 @@
11
package cc.unitmesh.devti.gui.toolbar
22

3-
import cc.unitmesh.devti.gui.AutoDevToolWindowFactory
3+
import cc.unitmesh.devti.history.ChatHistoryService
4+
import cc.unitmesh.devti.history.ChatSessionHistory
5+
import cc.unitmesh.devti.observer.agent.AgentStateService
46
import cc.unitmesh.devti.gui.AutoDevToolWindowFactory.AutoDevToolUtil
57
import cc.unitmesh.devti.settings.locale.LanguageChangedCallback.componentStateChanged
68
import cc.unitmesh.devti.sketch.SketchToolWindow
79
import com.intellij.icons.AllIcons
810
import com.intellij.openapi.actionSystem.*
911
import com.intellij.openapi.actionSystem.ex.CustomComponentAction
1012
import com.intellij.openapi.diagnostic.logger
13+
import com.intellij.openapi.project.Project
14+
import com.intellij.openapi.project.ProjectManager
1115
import com.intellij.openapi.wm.ToolWindowManager
1216
import com.intellij.ui.components.panels.Wrapper
1317
import com.intellij.util.ui.JBInsets
1418
import com.intellij.util.ui.JBUI
19+
import java.text.SimpleDateFormat
20+
import java.util.*
1521
import javax.swing.JButton
1622
import javax.swing.JComponent
1723

18-
class NewSketchAction : AnAction("New Sketch", "Create new Sketch", AllIcons.General.Add), CustomComponentAction {
19-
private val logger = logger<NewChatAction>()
24+
class NewSketchAction : AnAction(AllIcons.General.Add), CustomComponentAction {
25+
private val logger = logger<NewSketchAction>()
2026

27+
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
2128
override fun update(e: AnActionEvent) {
22-
e.presentation.text = "New Sketch"
29+
val project = e.project
30+
if (project == null) {
31+
e.presentation.isEnabled = false
32+
return
33+
}
34+
35+
val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(AutoDevToolUtil.ID)
36+
e.presentation.isEnabled = toolWindow?.isVisible ?: false
2337
}
2438

25-
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
2639
override fun actionPerformed(e: AnActionEvent) {
27-
newSketch(e.dataContext)
40+
val project = e.project ?: return
41+
val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(AutoDevToolUtil.ID)
42+
val contentManager = toolWindow?.contentManager
43+
val sketchPanel =
44+
contentManager?.component?.components?.filterIsInstance<SketchToolWindow>()?.firstOrNull()
45+
46+
saveCurrentSession(project, sketchPanel)
47+
sketchPanel?.resetSketchSession()
2848
}
2949

30-
override fun createCustomComponent(presentation: Presentation, place: String): JComponent {
31-
val button: JButton = object : JButton() {
32-
init {
33-
putClientProperty("ActionToolbar.smallVariant", true)
34-
putClientProperty("customButtonInsets", JBInsets(1, 1, 1, 1).asUIResource())
50+
private fun saveCurrentSession(project: Project, sketchToolWindow: SketchToolWindow?) {
51+
if (sketchToolWindow == null) return
3552

36-
setOpaque(false)
37-
addActionListener {
38-
val dataContext: DataContext = ActionToolbar.getDataContextFor(this)
39-
newSketch(dataContext)
40-
}
41-
}
42-
}.apply {
43-
componentStateChanged("chat.panel.newSketch", this) { b, d -> b.text = d }
44-
}
53+
val agentStateService = project.getService(AgentStateService::class.java) ?: return
54+
val chatHistoryService = project.getService(ChatHistoryService::class.java) ?: return
4555

46-
return Wrapper(button).also {
47-
it.setBorder(JBUI.Borders.empty(0, 10))
56+
val messages = agentStateService.getAllMessages()
57+
if (messages.isNotEmpty()) {
58+
val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
59+
val sessionName = "Session - $timestamp"
60+
chatHistoryService.saveSession(sessionName, messages)
61+
logger.info("Saved session: $sessionName")
4862
}
4963
}
5064

51-
private fun newSketch(dataContext: DataContext) {
52-
val project = dataContext.getData(CommonDataKeys.PROJECT)
53-
if (project == null) {
54-
logger.error("project is null")
55-
return
56-
}
65+
override fun createCustomComponent(presentation: Presentation, place: String): JComponent {
66+
val button: JButton = object : JButton(AllIcons.General.Add) {
67+
init {
68+
addActionListener {
69+
val project = ProjectManager.getInstance().openProjects.firstOrNull()
70+
if (project == null) {
71+
logger.warn("Cannot get project from component: $this")
72+
return@addActionListener
73+
}
5774

58-
val toolWindowManager = ToolWindowManager.getInstance(project).getToolWindow(AutoDevToolUtil.ID)
59-
val contentManager = toolWindowManager?.contentManager
75+
val toolWindow =
76+
ToolWindowManager.getInstance(project).getToolWindow(AutoDevToolUtil.ID)
77+
val contentManager = toolWindow?.contentManager
78+
val sketchPanel =
79+
contentManager?.component?.components?.filterIsInstance<SketchToolWindow>()?.firstOrNull()
6080

61-
val sketchPanel =
62-
contentManager?.component?.components?.filterIsInstance<SketchToolWindow>()?.firstOrNull()
81+
if (sketchPanel == null) {
82+
return@addActionListener
83+
}
84+
saveCurrentSession(project, sketchPanel)
85+
sketchPanel.resetSketchSession()
86+
}
87+
}
6388

64-
if (sketchPanel == null) {
65-
AutoDevToolWindowFactory.createSketchToolWindow(project, toolWindowManager!!)
89+
override fun getInsets(): JBInsets {
90+
return JBInsets.create(2, 2)
91+
}
6692
}
93+
button.isFocusable = false
94+
button.isOpaque = false
95+
button.toolTipText = presentation.text
96+
componentStateChanged(presentation.text, button) { b, d -> b.text = d }
6797

68-
sketchPanel?.resetSketchSession()
98+
val wrapper = Wrapper(button)
99+
wrapper.border = JBUI.Borders.empty(0, 2)
100+
return wrapper
69101
}
70-
}
102+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package cc.unitmesh.devti.gui.toolbar
2+
3+
import cc.unitmesh.devti.AutoDevBundle
4+
import cc.unitmesh.devti.AutoDevIcons
5+
import cc.unitmesh.devti.history.ChatHistoryService
6+
import cc.unitmesh.devti.history.ChatSessionHistory
7+
import cc.unitmesh.devti.llms.custom.Message
8+
import cc.unitmesh.devti.observer.agent.AgentStateService
9+
import cc.unitmesh.devti.sketch.SketchToolWindow
10+
import com.intellij.icons.AllIcons
11+
import com.intellij.openapi.actionSystem.ActionUpdateThread
12+
import com.intellij.openapi.actionSystem.AnAction
13+
import com.intellij.openapi.actionSystem.AnActionEvent
14+
import com.intellij.openapi.project.Project
15+
import com.intellij.openapi.ui.popup.JBPopupFactory
16+
import com.intellij.openapi.wm.ToolWindowManager
17+
import com.intellij.ui.components.JBList
18+
import java.text.SimpleDateFormat
19+
import java.util.*
20+
import javax.swing.ListSelectionModel
21+
22+
class ViewHistoryAction : AnAction(
23+
AutoDevBundle.message("action.view.history.text"),
24+
AutoDevBundle.message("action.view.history.description"),
25+
AutoDevIcons.HISTORY
26+
) {
27+
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
28+
29+
override fun actionPerformed(e: AnActionEvent) {
30+
val project = e.project ?: return
31+
val historyService = project.getService(ChatHistoryService::class.java)
32+
val sessions = historyService.getAllSessions().sortedByDescending { it.createdAt }
33+
34+
if (sessions.isEmpty()) {
35+
// Optionally show a message if no history is available
36+
return
37+
}
38+
39+
val listModel = sessions.map {
40+
val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(Date(it.createdAt))
41+
"${it.name} - $date"
42+
}
43+
44+
val jbList = JBList(listModel)
45+
jbList.selectionMode = ListSelectionModel.SINGLE_SELECTION
46+
47+
JBPopupFactory.getInstance()
48+
.createListPopupBuilder(jbList)
49+
.setTitle(AutoDevBundle.message("popup.title.session.history"))
50+
.setMovable(true)
51+
.setResizable(true)
52+
.setRequestFocus(true)
53+
.setItemChoosenCallback {
54+
val selectedIndex = jbList.selectedIndex
55+
if (selectedIndex != -1) {
56+
val selectedSession = sessions[selectedIndex]
57+
loadSessionIntoSketch(project, selectedSession)
58+
}
59+
}
60+
.createPopup()
61+
.showCenteredInCurrentWindow(project)
62+
}
63+
64+
private fun loadSessionIntoSketch(project: Project, session: ChatSessionHistory) {
65+
val toolWindowManager = ToolWindowManager.getInstance(project)
66+
val toolWindow = toolWindowManager.getToolWindow("AutoDev") ?: return
67+
val contentManager = toolWindow.contentManager
68+
val sketchPanel = contentManager.contents.firstNotNullOfOrNull { it.component as? SketchToolWindow }
69+
70+
sketchPanel?.let {
71+
// Clear current state in SketchToolWindow
72+
it.resetSketchSession()
73+
74+
// Load messages into AgentStateService
75+
val agentStateService = project.getService(AgentStateService::class.java)
76+
agentStateService.resetMessages() // Clear existing messages first
77+
session.messages.forEach { msg ->
78+
// We need to ensure messages are added in a way AgentStateService expects.
79+
// This might need adjustment based on how AgentStateService handles message addition.
80+
// For now, assuming a simple addMessage or similar.
81+
// If AgentStateService expects specific roles or types, this needs to be mapped.
82+
agentStateService.addMessage(Message(msg.role, msg.content))
83+
}
84+
85+
// Refresh or update SketchToolWindow UI to display loaded messages
86+
// This is a placeholder for the actual UI update logic in SketchToolWindow
87+
it.displayMessages(session.messages)
88+
89+
// Potentially set the "intention" or context if that's part of the session
90+
// For example, if the first user message is considered the intention:
91+
session.messages.firstOrNull { msg -> msg.role == "user" }?.content?.let { intention ->
92+
agentStateService.state = agentStateService.state.copy(originIntention = intention)
93+
}
94+
95+
toolWindow.activate(null)
96+
}
97+
}
98+
99+
override fun update(e: AnActionEvent) {
100+
e.presentation.isEnabledAndVisible = e.project != null
101+
}
102+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package cc.unitmesh.devti.history
2+
3+
import cc.unitmesh.devti.llms.custom.Message
4+
import com.intellij.openapi.Disposable
5+
import com.intellij.openapi.components.Service
6+
import com.intellij.openapi.project.Project
7+
import jetbrains.exodus.entitystore.PersistentEntityStore
8+
import jetbrains.exodus.entitystore.PersistentEntityStores
9+
import jetbrains.exodus.env.Environment
10+
import jetbrains.exodus.env.Environments
11+
import kotlinx.serialization.encodeToString
12+
import kotlinx.serialization.json.Json
13+
import java.nio.file.Paths
14+
import java.util.*
15+
16+
@Service(Service.Level.PROJECT)
17+
class ChatHistoryService(private val project: Project) : Disposable {
18+
private val storeName = "chatHistory"
19+
private val entityType = "ChatSession"
20+
21+
private val environment: Environment by lazy {
22+
val projectBasePath = project.basePath ?: throw IllegalStateException("Project base path is null")
23+
val dbPath = Paths.get(projectBasePath, ".idea", "autodev", "history").toString()
24+
Environments.newInstance(dbPath)
25+
}
26+
27+
private val entityStore: PersistentEntityStore by lazy {
28+
PersistentEntityStores.newInstance(environment, storeName)
29+
}
30+
31+
fun saveSession(name: String, messages: List<Message>): ChatSessionHistory {
32+
val sessionId = UUID.randomUUID().toString()
33+
val history = ChatSessionHistory(sessionId, name, messages, System.currentTimeMillis())
34+
val jsonHistory = Json.encodeToString(history)
35+
36+
entityStore.executeInTransaction { txn ->
37+
val entity = txn.newEntity(entityType)
38+
entity.setProperty("id", sessionId)
39+
entity.setProperty("name", name)
40+
entity.setBlobString("messages", jsonHistory)
41+
entity.setProperty("createdAt", history.createdAt)
42+
}
43+
return history
44+
}
45+
46+
fun getSession(sessionId: String): ChatSessionHistory? {
47+
var history: ChatSessionHistory? = null
48+
entityStore.executeInReadonlyTransaction { txn ->
49+
val entity = txn.find(entityType, "id", sessionId).firstOrNull()
50+
entity?.let {
51+
val jsonHistory = it.getBlobString("messages")
52+
if (jsonHistory != null) {
53+
history = Json.decodeFromString<ChatSessionHistory>(jsonHistory)
54+
}
55+
}
56+
}
57+
return history
58+
}
59+
60+
fun getAllSessions(): List<ChatSessionHistory> {
61+
val histories = mutableListOf<ChatSessionHistory>()
62+
entityStore.executeInReadonlyTransaction { txn ->
63+
txn.getAll(entityType).forEach { entity ->
64+
val jsonHistory = entity.getBlobString("messages")
65+
if (jsonHistory != null) {
66+
try {
67+
histories.add(Json.decodeFromString<ChatSessionHistory>(jsonHistory))
68+
} catch (e: Exception) {
69+
// Log error or handle corrupted data
70+
println("Error decoding session history: ${entity.getProperty("id")}, ${e.message}")
71+
}
72+
}
73+
}
74+
}
75+
// Sort by creation date, newest first
76+
return histories.sortedByDescending { it.createdAt }
77+
}
78+
79+
fun deleteSession(sessionId: String): Boolean {
80+
var deleted = false
81+
entityStore.executeInTransaction { txn ->
82+
val entity = txn.find(entityType, "id", sessionId).firstOrNull()
83+
entity?.let {
84+
deleted = it.delete()
85+
}
86+
}
87+
return deleted
88+
}
89+
90+
override fun dispose() {
91+
entityStore.close()
92+
environment.close()
93+
}
94+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package cc.unitmesh.devti.history
2+
3+
import cc.unitmesh.devti.llms.custom.Message
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class ChatSessionHistory(
8+
val id: String,
9+
val name: String,
10+
val messages: List<Message>,
11+
val createdAt: Long
12+
)

core/src/main/kotlin/cc/unitmesh/devti/observer/agent/AgentStateService.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@ class AgentStateService(val project: Project) {
101101
updatePlan(planItems.toMutableList())
102102
}
103103

104+
/**
105+
* Reset all messages in the agent state
106+
*/
107+
fun resetMessages() {
108+
state = state.copy(messages = emptyList())
109+
}
110+
111+
/**
112+
* Add a single message to the agent state
113+
*/
114+
fun addMessage(message: Message) {
115+
state = state.copy(messages = state.messages + message)
116+
}
117+
104118
fun resetState() {
105119
state = AgentState()
106120
val syncPublisher = ApplicationManager.getApplication().messageBus

0 commit comments

Comments
 (0)