Skip to content

Commit 3c07ca9

Browse files
committed
Include title in message events.
1 parent 9652338 commit 3c07ca9

File tree

5 files changed

+235
-21
lines changed

5 files changed

+235
-21
lines changed

README.md

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# Embabel Chat Store
2+
3+
Conversation persistence layer for Embabel Agent. Provides pluggable storage backends for chat sessions - in-memory for development/testing, Neo4j for production.
4+
5+
## Quick Start
6+
7+
```kotlin
8+
// Get a factory for your storage type
9+
val factory = conversationFactoryProvider.get(ConversationStoreType.STORED)
10+
11+
// Create a conversation for a 1-1 chat
12+
val conversation = (factory as StoredConversationFactory)
13+
.createForParticipants(
14+
id = sessionId,
15+
user = currentUser,
16+
agent = assistantUser,
17+
title = "My Chat"
18+
)
19+
20+
// Add messages - automatically routed based on role
21+
conversation.addMessage(UserMessage("Hello!")) // from=user, to=agent
22+
conversation.addMessage(AssistantMessage("Hi!")) // from=agent, to=user
23+
```
24+
25+
## Architecture
26+
27+
```
28+
ConversationFactoryProvider
29+
├── InMemoryConversationFactory → InMemoryConversation (ephemeral)
30+
└── StoredConversationFactory → StoredConversation (Neo4j)
31+
```
32+
33+
### Storage Types
34+
35+
| Type | Class | Use Case |
36+
|------|-------|----------|
37+
| `IN_MEMORY` | `InMemoryConversation` | Testing, ephemeral chats |
38+
| `STORED` | `StoredConversation` | Production, persistent chats |
39+
40+
## Message Events
41+
42+
Subscribe to message lifecycle events for real-time updates:
43+
44+
```kotlin
45+
@EventListener
46+
fun onMessage(event: MessageEvent) {
47+
when (event.status) {
48+
MessageStatus.ADDED -> {
49+
// Message added - update UI immediately
50+
sendToWebSocket(event.toUserId, event)
51+
}
52+
MessageStatus.PERSISTED -> {
53+
// Message saved to storage
54+
}
55+
MessageStatus.PERSISTENCE_FAILED -> {
56+
// Handle error - event.error has details
57+
}
58+
}
59+
}
60+
```
61+
62+
### Event Fields
63+
64+
| Field | Description |
65+
|-------|-------------|
66+
| `conversationId` | Session ID |
67+
| `fromUserId` | Who sent the message |
68+
| `toUserId` | Who should receive it (for routing) |
69+
| `title` | Session title (for UI display) |
70+
| `message` | The message content |
71+
| `status` | ADDED, PERSISTED, or PERSISTENCE_FAILED |
72+
73+
## Message Attribution
74+
75+
Messages have explicit sender and recipient:
76+
77+
```
78+
(message:StoredMessage)-[:AUTHORED_BY]->(from:SessionUser)
79+
(message:StoredMessage)-[:SENT_TO]->(to:SessionUser)
80+
```
81+
82+
### Role-Based Routing (1-1 Chats)
83+
84+
| Role | From | To |
85+
|------|------|-----|
86+
| USER | user | agent |
87+
| ASSISTANT | agent | user |
88+
| SYSTEM | null | user |
89+
90+
### Multi-Party Chats
91+
92+
For multi-user or multi-agent scenarios, use `addMessageFromTo` for explicit routing:
93+
94+
```kotlin
95+
// Create conversation without default participants
96+
val conversation = factory.create(sessionId)
97+
98+
// Group chat with multiple users
99+
conversation.addMessageFromTo(UserMessage("Hi everyone!"), from = alice, to = bob)
100+
conversation.addMessageFromTo(UserMessage("Hello Alice!"), from = bob, to = alice)
101+
conversation.addMessageFromTo(UserMessage("Hey both!"), from = charlie, to = alice)
102+
103+
// Agent handoff - one agent passing to another
104+
conversation.addMessageFromTo(
105+
AssistantMessage("Transferring you to billing..."),
106+
from = supportAgent,
107+
to = customer
108+
)
109+
conversation.addMessageFromTo(
110+
AssistantMessage("Hi, I'm the billing specialist."),
111+
from = billingAgent,
112+
to = customer
113+
)
114+
115+
// Multi-agent collaboration
116+
conversation.addMessageFromTo(
117+
AssistantMessage("I'll research that."),
118+
from = researchAgent,
119+
to = coordinatorAgent
120+
)
121+
conversation.addMessageFromTo(
122+
AssistantMessage("Here's what I found..."),
123+
from = researchAgent,
124+
to = customer
125+
)
126+
```
127+
128+
#### Future Enhancements
129+
130+
Planned support for:
131+
- Group recipients (send to multiple users at once)
132+
- Participant lists on conversations
133+
- Agent-to-agent communication patterns
134+
- Broadcast messages
135+
136+
## Auto Title Generation
137+
138+
Provide a `TitleGenerator` to automatically generate session titles:
139+
140+
```kotlin
141+
val factory = StoredConversationFactory(
142+
repository = chatSessionRepository,
143+
eventPublisher = applicationEventPublisher,
144+
titleGenerator = { message ->
145+
// Generate title from first message
146+
llm.generate("Summarize in 5 words: ${message.content}")
147+
}
148+
)
149+
```
150+
151+
## Session User
152+
153+
Implement `SessionUser` for your user type:
154+
155+
```kotlin
156+
@NodeFragment(labels = ["SessionUser", "MyUser"])
157+
data class MyUser(
158+
@NodeId override val id: String,
159+
override val displayName: String,
160+
val email: String
161+
) : SessionUser
162+
```
163+
164+
Register with Drivine:
165+
166+
```kotlin
167+
persistenceManager.registerSubtype(
168+
SessionUser::class.java,
169+
"MyUser|SessionUser",
170+
MyUser::class.java
171+
)
172+
```
173+
174+
## Spring Boot Auto-Configuration
175+
176+
Add the dependency and configure:
177+
178+
```yaml
179+
embabel:
180+
chat:
181+
store:
182+
enabled: true # default
183+
```
184+
185+
Beans auto-configured:
186+
- `storedConversationFactory` - for persistent conversations
187+
- `inMemoryConversationFactory` - for ephemeral conversations
188+
- `conversationFactoryProvider` - aggregates all factories
189+
190+
## Dependencies
191+
192+
- `embabel-agent-api` - Core conversation interfaces
193+
- `drivine` - Neo4j graph persistence
194+
- Spring Boot (optional, for auto-configuration)

pom.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
<groupId>com.embabel.agent</groupId>
1414
<artifactId>embabel-chat-store</artifactId>
15-
<version>0.1.0-SNAPSHOT</version>
15+
<version>0.2.0-SNAPSHOT</version>
1616
<packaging>jar</packaging>
1717
<name>Embabel Chat Store</name>
1818
<description>Chat session storage library for Embabel Agent using Neo4j and Drivine</description>
@@ -194,4 +194,4 @@
194194
</snapshotRepository>
195195
</distributionManagement>
196196

197-
</project>
197+
</project>

src/main/kotlin/com/embabel/chat/event/ConversationEvents.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ enum class MessageStatus {
8585
* @param status the current status of the message
8686
* @param fromUserId the ID of the user who sent this message (author)
8787
* @param toUserId the ID of the user who should receive this message (for routing, e.g., WebSocket)
88+
* @param title the session/conversation title (for UI display)
8889
* @param message the message (always present for ADDED, present for PERSISTED)
8990
* @param content the message content (useful for PERSISTENCE_FAILED when message ref may be stale)
9091
* @param role the message role
@@ -96,6 +97,7 @@ data class MessageEvent(
9697
val status: MessageStatus,
9798
val fromUserId: String? = null,
9899
val toUserId: String? = null,
100+
val title: String? = null,
99101
val message: Message? = null,
100102
val content: String? = null,
101103
val role: Role? = null,
@@ -111,12 +113,14 @@ data class MessageEvent(
111113
conversationId: String,
112114
message: Message,
113115
fromUserId: String? = null,
114-
toUserId: String? = null
116+
toUserId: String? = null,
117+
title: String? = null
115118
) = MessageEvent(
116119
conversationId = conversationId,
117120
status = MessageStatus.ADDED,
118121
fromUserId = fromUserId,
119122
toUserId = toUserId,
123+
title = title,
120124
message = message,
121125
content = message.content,
122126
role = message.role
@@ -129,12 +133,14 @@ data class MessageEvent(
129133
conversationId: String,
130134
message: Message,
131135
fromUserId: String? = null,
132-
toUserId: String? = null
136+
toUserId: String? = null,
137+
title: String? = null
133138
) = MessageEvent(
134139
conversationId = conversationId,
135140
status = MessageStatus.PERSISTED,
136141
fromUserId = fromUserId,
137142
toUserId = toUserId,
143+
title = title,
138144
message = message,
139145
content = message.content,
140146
role = message.role
@@ -149,12 +155,14 @@ data class MessageEvent(
149155
role: Role,
150156
error: Throwable,
151157
fromUserId: String? = null,
152-
toUserId: String? = null
158+
toUserId: String? = null,
159+
title: String? = null
153160
) = MessageEvent(
154161
conversationId = conversationId,
155162
status = MessageStatus.PERSISTENCE_FAILED,
156163
fromUserId = fromUserId,
157164
toUserId = toUserId,
165+
title = title,
158166
content = content,
159167
role = role,
160168
error = error

src/main/kotlin/com/embabel/chat/store/adapter/StoredConversation.kt

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import java.util.UUID
8383
* @param eventPublisher Spring's event publisher for broadcasting events
8484
* @param user the human user participant (author of USER messages, recipient of ASSISTANT messages)
8585
* @param agent the AI/system user participant (author of ASSISTANT messages, recipient of USER messages)
86+
* @param title the session title (included in events for UI display)
8687
* @param titleGenerator optional generator for auto-generating session title from first message
8788
* @param assetTracker tracker for conversation assets (defaults to in-memory)
8889
* @param scope coroutine scope for async operations (defaults to IO dispatcher with SupervisorJob)
@@ -93,6 +94,7 @@ class StoredConversation(
9394
private val eventPublisher: ApplicationEventPublisher? = null,
9495
private val user: SessionUser? = null,
9596
private val agent: SessionUser? = null,
97+
private var title: String? = null,
9698
private val titleGenerator: TitleGenerator? = null,
9799
override val assetTracker: AssetTracker = InMemoryAssetTracker(),
98100
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
@@ -190,7 +192,7 @@ class StoredConversation(
190192

191193
// Publish ADDED event synchronously before async persistence
192194
eventPublisher?.publishEvent(
193-
MessageEvent.added(id, message, from?.id, to?.id)
195+
MessageEvent.added(id, message, from?.id, to?.id, title)
194196
)
195197

196198
scope.launch {
@@ -199,21 +201,18 @@ class StoredConversation(
199201
val persistedMessage = updatedSession.messages.last().toMessage()
200202

201203
// Generate title from first message if no title exists
202-
if (isFirstMessage && titleGenerator != null) {
203-
val session = repository.findBySessionId(id).orElse(null)
204-
if (session?.session?.title.isNullOrBlank()) {
205-
try {
206-
val title = titleGenerator.generate(message)
207-
repository.updateSessionTitle(id, title)
208-
logger.debug("Generated title '{}' for session {}", title, id)
209-
} catch (e: Exception) {
210-
logger.warn("Failed to generate title for session {}: {}", id, e.message)
211-
}
204+
if (isFirstMessage && titleGenerator != null && title.isNullOrBlank()) {
205+
try {
206+
title = titleGenerator.generate(message)
207+
repository.updateSessionTitle(id, title!!)
208+
logger.debug("Generated title '{}' for session {}", title, id)
209+
} catch (e: Exception) {
210+
logger.warn("Failed to generate title for session {}: {}", id, e.message)
212211
}
213212
}
214213

215214
eventPublisher?.publishEvent(
216-
MessageEvent.persisted(id, persistedMessage, from?.id, to?.id)
215+
MessageEvent.persisted(id, persistedMessage, from?.id, to?.id, title)
217216
)
218217
logger.debug("Message {} persisted to session {}", messageData.messageId, id)
219218
} catch (e: Exception) {
@@ -225,7 +224,8 @@ class StoredConversation(
225224
role = message.role,
226225
error = e,
227226
fromUserId = from?.id,
228-
toUserId = to?.id
227+
toUserId = to?.id,
228+
title = title
229229
)
230230
)
231231
}

src/main/kotlin/com/embabel/chat/store/adapter/StoredConversationFactory.kt

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,31 @@ class StoredConversationFactory @JvmOverloads constructor(
7777
* @param id the conversation/session ID
7878
* @param user the human user participant
7979
* @param agent the AI/system user participant (optional, can be set later)
80+
* @param title the session title (included in events for UI display)
8081
*/
8182
@JvmOverloads
82-
fun createForParticipants(id: String, user: SessionUser, agent: SessionUser? = null): Conversation {
83-
return createInternal(id, user, agent)
83+
fun createForParticipants(
84+
id: String,
85+
user: SessionUser,
86+
agent: SessionUser? = null,
87+
title: String? = null
88+
): Conversation {
89+
return createInternal(id, user, agent, title)
8490
}
8591

86-
private fun createInternal(id: String, user: SessionUser?, agent: SessionUser?): Conversation {
92+
private fun createInternal(
93+
id: String,
94+
user: SessionUser?,
95+
agent: SessionUser?,
96+
title: String? = null
97+
): Conversation {
8798
return StoredConversation(
8899
id = id,
89100
repository = repository,
90101
eventPublisher = eventPublisher,
91102
user = user,
92103
agent = agent,
104+
title = title,
93105
titleGenerator = titleGenerator,
94106
assetTracker = InMemoryAssetTracker(),
95107
scope = scope

0 commit comments

Comments
 (0)