Enhance chat buffer and streaming message handling
- Updated the `ChatBuffer` to support streaming messages, allowing for content to be appended and finalized with `SendStream` and `End` methods. - Modified the `AddAssistantMessage` method to include a message ID, improving message tracking and retrieval. - Implemented new methods for appending content to streaming messages and completing them, ensuring accurate message storage and event handling. - Revised tests to validate the new streaming functionality and ensure proper integration with existing message handling processes. - Updated `CHAT_STORAGE_DESIGN.md` to reflect changes in message storage and indexing, including unique constraints for message IDs within requests.
This commit is contained in:
parent
a8a1103b6b
commit
55c8635c2a
12 changed files with 1480 additions and 187 deletions
|
|
@ -580,8 +580,9 @@ func TestFlushBuffer(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
|
||||
// Add some messages to buffer
|
||||
require.NotNil(t, ctx.Buffer, "Buffer should be initialized")
|
||||
ctx.Buffer.AddUserInput("Test question", "")
|
||||
ctx.Buffer.AddAssistantMessage("text", map[string]interface{}{"content": "Test answer"}, "", "", ast.ID, nil)
|
||||
ctx.Buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Test answer"}, "", "", ast.ID, nil)
|
||||
|
||||
// Add a step
|
||||
ast.BeginStep(ctx, agentcontext.StepTypeLLM, nil)
|
||||
|
|
|
|||
|
|
@ -365,6 +365,7 @@ func (s *streamState) handleMessageEnd(data []byte) int {
|
|||
}
|
||||
|
||||
s.ctx.Buffer.AddAssistantMessage(
|
||||
s.currentGroupID, // Use the message ID
|
||||
msgType,
|
||||
props,
|
||||
blockID,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,18 @@ interface Context {
|
|||
|
||||
### Send Messages
|
||||
|
||||
The Context provides several methods for sending messages to the client:
|
||||
|
||||
| Method | Description | Auto `message_end` |
|
||||
| ----------------------------------- | --------------------------- | ------------------ |
|
||||
| `Send(message, blockId?)` | Send a complete message | ✅ Yes |
|
||||
| `SendStream(message, blockId?)` | Start a streaming message | ❌ No |
|
||||
| `Append(messageId, content, path?)` | Append content to a message | N/A |
|
||||
| `Replace(messageId, message)` | Replace message content | N/A |
|
||||
| `Merge(messageId, data, path?)` | Merge data into message | N/A |
|
||||
| `Set(messageId, data, path)` | Set a field in message | N/A |
|
||||
| `End(messageId, finalContent?)` | Finalize streaming message | ✅ Yes |
|
||||
|
||||
#### `ctx.Send(message, blockId?): string`
|
||||
|
||||
Sends a message to the client and automatically flushes the output.
|
||||
|
|
@ -191,6 +203,139 @@ function Next(ctx, payload) {
|
|||
- Output is automatically flushed after sending
|
||||
- Throws exception on failure
|
||||
- Delta operations (Replace, Append, Merge, Set) automatically inherit block_id and thread_id from the original message
|
||||
- **For streaming output**, use `ctx.SendStream()` instead (see below)
|
||||
|
||||
#### `ctx.SendStream(message, blockId?): string`
|
||||
|
||||
Sends a streaming message that can be appended to later. Unlike `Send()`, this does NOT automatically send `message_end` event. Use `ctx.Append()` to add content, then `ctx.End()` to finalize.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `message`: Message object or string
|
||||
- `blockId`: String (optional) - Block ID to send this message in
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `string`: The message ID (for use with `Append` and `End`)
|
||||
|
||||
**Examples:**
|
||||
|
||||
```javascript
|
||||
// Start a streaming message
|
||||
const msgId = ctx.SendStream({
|
||||
type: "text",
|
||||
props: { content: "# Title\n\n" },
|
||||
});
|
||||
|
||||
// Append content in chunks (simulating streaming)
|
||||
ctx.Append(msgId, "First paragraph. ");
|
||||
ctx.Append(msgId, "Second sentence. ");
|
||||
ctx.Append(msgId, "Third sentence.\n\n");
|
||||
|
||||
// Finalize the message (sends message_end event)
|
||||
ctx.End(msgId);
|
||||
```
|
||||
|
||||
**String Shorthand:**
|
||||
|
||||
```javascript
|
||||
// SendStream with string shorthand
|
||||
const msgId = ctx.SendStream("Starting analysis...");
|
||||
ctx.Append(msgId, " processing...");
|
||||
ctx.Append(msgId, " done!");
|
||||
ctx.End(msgId);
|
||||
// Final content: "Starting analysis... processing... done!"
|
||||
```
|
||||
|
||||
**With Block ID:**
|
||||
|
||||
```javascript
|
||||
const blockId = ctx.BlockID();
|
||||
const msgId = ctx.SendStream("Step 1: ", blockId);
|
||||
ctx.Append(msgId, "Analyzing data...");
|
||||
ctx.End(msgId);
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Returns the message ID immediately for use with `Append` and `End`
|
||||
- Sends `message_start` event but NOT `message_end` (unlike `Send`)
|
||||
- Must call `ctx.End(msgId)` to finalize the message
|
||||
- Content appended via `ctx.Append()` is accumulated for storage
|
||||
- Ideal for streaming text output where you control the timing
|
||||
|
||||
#### `ctx.End(messageId, finalContent?): string`
|
||||
|
||||
Finalizes a streaming message started with `SendStream()`. Sends `message_end` event with the complete accumulated content.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `messageId`: String - The message ID returned by `SendStream()`
|
||||
- `finalContent`: String (optional) - Final content to append before ending
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `string`: The message ID
|
||||
|
||||
**Examples:**
|
||||
|
||||
```javascript
|
||||
// Basic usage
|
||||
const msgId = ctx.SendStream("Hello");
|
||||
ctx.Append(msgId, " World");
|
||||
ctx.End(msgId);
|
||||
// Final: "Hello World"
|
||||
|
||||
// End with final content
|
||||
const msgId2 = ctx.SendStream("Processing");
|
||||
ctx.Append(msgId2, "...");
|
||||
ctx.End(msgId2, " Complete!");
|
||||
// Final: "Processing... Complete!"
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Must be called after `SendStream()` to send `message_end` event
|
||||
- Optional `finalContent` is appended before sending `message_end`
|
||||
- The complete accumulated content is included in `message_end.extra.content`
|
||||
- Throws exception if `messageId` is not a string
|
||||
|
||||
**Send vs SendStream Comparison:**
|
||||
|
||||
| Feature | `Send()` | `SendStream()` |
|
||||
| --------------------- | ----------------- | ------------------- |
|
||||
| `message_start` event | ✅ Auto | ✅ Auto |
|
||||
| `message_end` event | ✅ Auto | ❌ Manual (`End()`) |
|
||||
| Use case | Complete messages | Streaming output |
|
||||
| Content accumulation | N/A | Via `Append()` |
|
||||
| Storage | Immediate | On `End()` |
|
||||
|
||||
**Streaming Workflow Example:**
|
||||
|
||||
```javascript
|
||||
function Create(ctx, messages) {
|
||||
// Start streaming output
|
||||
const msgId = ctx.SendStream({
|
||||
type: "text",
|
||||
props: { content: "# Analysis Report\n\n" },
|
||||
});
|
||||
|
||||
// Simulate streaming chunks
|
||||
ctx.Append(msgId, "## Section 1\n");
|
||||
ctx.Append(msgId, "Processing data...\n\n");
|
||||
|
||||
// Do some work
|
||||
const result = analyzeData();
|
||||
|
||||
ctx.Append(msgId, "## Section 2\n");
|
||||
ctx.Append(msgId, `Found ${result.count} items.\n\n`);
|
||||
|
||||
// Finalize with conclusion
|
||||
ctx.End(msgId, "## Conclusion\nAnalysis complete.");
|
||||
|
||||
return { messages };
|
||||
}
|
||||
```
|
||||
|
||||
#### `ctx.Replace(messageId, message): string`
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ type BufferedMessage struct {
|
|||
Sequence int `json:"sequence"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
IsStreaming bool `json:"-"` // Internal flag: true if message is still streaming (not saved until End)
|
||||
}
|
||||
|
||||
// BufferedStep represents an execution step waiting to be saved (for Resume)
|
||||
|
|
@ -160,13 +161,14 @@ func (b *ChatBuffer) AddUserInput(content interface{}, name string) {
|
|||
|
||||
// AddAssistantMessage adds an assistant message to the buffer
|
||||
// This is called by ctx.Send() to buffer messages for batch saving
|
||||
func (b *ChatBuffer) AddAssistantMessage(msgType string, props map[string]interface{}, blockID, threadID, assistantID string, metadata map[string]interface{}) {
|
||||
func (b *ChatBuffer) AddAssistantMessage(messageID, msgType string, props map[string]interface{}, blockID, threadID, assistantID string, metadata map[string]interface{}) {
|
||||
// Skip event type messages (transient, not stored)
|
||||
if msgType == "event" {
|
||||
return
|
||||
}
|
||||
|
||||
b.AddMessage(&BufferedMessage{
|
||||
MessageID: messageID, // Use the same MessageID as sent to client
|
||||
Role: "assistant",
|
||||
Type: msgType,
|
||||
Props: props,
|
||||
|
|
@ -178,6 +180,93 @@ func (b *ChatBuffer) AddAssistantMessage(msgType string, props map[string]interf
|
|||
})
|
||||
}
|
||||
|
||||
// AddStreamingMessage adds a streaming message to the buffer
|
||||
// Streaming messages are not saved until CompleteStreamingMessage is called
|
||||
// This is called by ctx.SendStream() to start a streaming message
|
||||
func (b *ChatBuffer) AddStreamingMessage(messageID, msgType string, props map[string]interface{}, blockID, threadID, assistantID string, metadata map[string]interface{}) {
|
||||
// Skip event type messages (transient, not stored)
|
||||
if msgType == "event" {
|
||||
return
|
||||
}
|
||||
|
||||
// Deep copy props to avoid mutation issues
|
||||
propsCopy := make(map[string]interface{})
|
||||
for k, v := range props {
|
||||
propsCopy[k] = v
|
||||
}
|
||||
|
||||
b.AddMessage(&BufferedMessage{
|
||||
MessageID: messageID, // Use provided message ID
|
||||
Role: "assistant",
|
||||
Type: msgType,
|
||||
Props: propsCopy,
|
||||
BlockID: blockID,
|
||||
ThreadID: threadID,
|
||||
AssistantID: assistantID,
|
||||
Connector: b.connector,
|
||||
Metadata: metadata,
|
||||
IsStreaming: true, // Mark as streaming
|
||||
})
|
||||
}
|
||||
|
||||
// AppendMessageContent appends content to a streaming message
|
||||
// This is called by ctx.Append() to accumulate content
|
||||
func (b *ChatBuffer) AppendMessageContent(messageID string, content string) bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
// Find the message by ID
|
||||
for _, msg := range b.messages {
|
||||
if msg.MessageID == messageID && msg.IsStreaming {
|
||||
// Append to existing content
|
||||
if msg.Props == nil {
|
||||
msg.Props = make(map[string]interface{})
|
||||
}
|
||||
if existing, ok := msg.Props["content"].(string); ok {
|
||||
msg.Props["content"] = existing + content
|
||||
} else {
|
||||
msg.Props["content"] = content
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CompleteStreamingMessage marks a streaming message as complete
|
||||
// This is called by ctx.End() to finalize the message
|
||||
// Returns the complete content for the message_end event
|
||||
func (b *ChatBuffer) CompleteStreamingMessage(messageID string) (string, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
// Find the message by ID
|
||||
for _, msg := range b.messages {
|
||||
if msg.MessageID == messageID && msg.IsStreaming {
|
||||
msg.IsStreaming = false
|
||||
// Return the accumulated content
|
||||
if content, ok := msg.Props["content"].(string); ok {
|
||||
return content, true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// GetStreamingMessage returns a streaming message by ID
|
||||
func (b *ChatBuffer) GetStreamingMessage(messageID string) *BufferedMessage {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
for _, msg := range b.messages {
|
||||
if msg.MessageID == messageID && msg.IsStreaming {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMessages returns all buffered messages
|
||||
func (b *ChatBuffer) GetMessages() []*BufferedMessage {
|
||||
b.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
|
|||
t.Run("AddTextMessage", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "")
|
||||
buffer.AddAssistantMessage(
|
||||
"M1",
|
||||
"text",
|
||||
map[string]interface{}{"content": "Hello, how can I help?"},
|
||||
"block-1",
|
||||
|
|
@ -175,6 +176,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
|
|||
|
||||
messages := buffer.GetMessages()
|
||||
require.Len(t, messages, 1)
|
||||
assert.Equal(t, "M1", messages[0].MessageID)
|
||||
assert.Equal(t, "assistant", messages[0].Role)
|
||||
assert.Equal(t, "text", messages[0].Type)
|
||||
assert.Equal(t, "block-1", messages[0].BlockID)
|
||||
|
|
@ -186,6 +188,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
|
|||
t.Run("SkipEventMessage", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-2", "req-2", "assistant-2", "")
|
||||
buffer.AddAssistantMessage(
|
||||
"E1",
|
||||
"event",
|
||||
map[string]interface{}{"event": "message_start"},
|
||||
"", "", "", nil,
|
||||
|
|
@ -198,6 +201,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
|
|||
t.Run("AddRetrievalMessage", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-3", "req-3", "assistant-3", "")
|
||||
buffer.AddAssistantMessage(
|
||||
"M2",
|
||||
"retrieval",
|
||||
map[string]interface{}{
|
||||
"sources": []map[string]interface{}{
|
||||
|
|
@ -216,6 +220,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
|
|||
t.Run("AddToolCallMessage", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-4", "req-4", "assistant-4", "")
|
||||
buffer.AddAssistantMessage(
|
||||
"M3",
|
||||
"tool_call",
|
||||
map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
|
|
@ -233,6 +238,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
|
|||
t.Run("AddCustomTypeMessage", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-5", "req-5", "assistant-5", "")
|
||||
buffer.AddAssistantMessage(
|
||||
"M4",
|
||||
"custom_chart",
|
||||
map[string]interface{}{
|
||||
"chart_type": "bar",
|
||||
|
|
@ -277,7 +283,7 @@ func TestBufferGetMessageCount(t *testing.T) {
|
|||
buffer.AddUserInput("Message 1", "")
|
||||
assert.Equal(t, 1, buffer.GetMessageCount())
|
||||
|
||||
buffer.AddAssistantMessage("text", map[string]interface{}{"content": "Reply"}, "", "", "", nil)
|
||||
buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Reply"}, "", "", "", nil)
|
||||
assert.Equal(t, 2, buffer.GetMessageCount())
|
||||
}
|
||||
|
||||
|
|
@ -650,6 +656,7 @@ func TestBufferConnectorInMessages(t *testing.T) {
|
|||
|
||||
// Add assistant message - should inherit connector from buffer
|
||||
buffer.AddAssistantMessage(
|
||||
"M1",
|
||||
"text",
|
||||
map[string]interface{}{"content": "Hello"},
|
||||
"block-1", "thread-1", "assistant-1", nil,
|
||||
|
|
@ -665,6 +672,7 @@ func TestBufferConnectorInMessages(t *testing.T) {
|
|||
|
||||
// First message with openai
|
||||
buffer.AddAssistantMessage(
|
||||
"M1",
|
||||
"text",
|
||||
map[string]interface{}{"content": "Using OpenAI"},
|
||||
"", "", "assistant-1", nil,
|
||||
|
|
@ -675,6 +683,7 @@ func TestBufferConnectorInMessages(t *testing.T) {
|
|||
|
||||
// Second message with anthropic
|
||||
buffer.AddAssistantMessage(
|
||||
"M2",
|
||||
"text",
|
||||
map[string]interface{}{"content": "Now using Claude"},
|
||||
"", "", "assistant-1", nil,
|
||||
|
|
@ -707,6 +716,7 @@ func TestBufferConnectorInMessages(t *testing.T) {
|
|||
for i, conn := range connectors {
|
||||
buffer.SetConnector(conn)
|
||||
buffer.AddAssistantMessage(
|
||||
fmt.Sprintf("M%d", i+1),
|
||||
"text",
|
||||
map[string]interface{}{"content": fmt.Sprintf("Message %d", i+1)},
|
||||
"", "", "assistant-1", nil,
|
||||
|
|
@ -908,8 +918,8 @@ func TestBufferEdgeCases(t *testing.T) {
|
|||
"custom_type_1", "custom_type_2",
|
||||
}
|
||||
|
||||
for _, msgType := range messageTypes {
|
||||
buffer.AddAssistantMessage(msgType, map[string]interface{}{"type": msgType}, "", "", "", nil)
|
||||
for i, msgType := range messageTypes {
|
||||
buffer.AddAssistantMessage(fmt.Sprintf("M%d", i+1), msgType, map[string]interface{}{"type": msgType}, "", "", "", nil)
|
||||
}
|
||||
|
||||
assert.Equal(t, len(messageTypes), buffer.GetMessageCount())
|
||||
|
|
@ -948,12 +958,12 @@ func TestBufferCompleteWorkflow(t *testing.T) {
|
|||
|
||||
// 2. Create hook
|
||||
buffer.BeginStep(context.StepTypeHookCreate, nil, nil)
|
||||
buffer.AddAssistantMessage("thinking", map[string]interface{}{"content": "Processing your request..."}, "block-1", "", "assistant-main", nil)
|
||||
buffer.AddAssistantMessage("M1", "thinking", map[string]interface{}{"content": "Processing your request..."}, "block-1", "", "assistant-main", nil)
|
||||
buffer.CompleteStep(nil)
|
||||
|
||||
// 3. LLM call with tool
|
||||
buffer.BeginStep(context.StepTypeLLM, map[string]interface{}{"model": "gpt-4"}, nil)
|
||||
buffer.AddAssistantMessage("tool_call", map[string]interface{}{
|
||||
buffer.AddAssistantMessage("M2", "tool_call", map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
"arguments": `{"location":"San Francisco"}`,
|
||||
}, "block-2", "", "assistant-main", nil)
|
||||
|
|
@ -961,14 +971,14 @@ func TestBufferCompleteWorkflow(t *testing.T) {
|
|||
|
||||
// 4. Tool execution
|
||||
buffer.BeginStep(context.StepTypeTool, map[string]interface{}{"tool": "get_weather"}, nil)
|
||||
buffer.AddAssistantMessage("tool_result", map[string]interface{}{
|
||||
buffer.AddAssistantMessage("M3", "tool_result", map[string]interface{}{
|
||||
"result": "72°F, Sunny",
|
||||
}, "block-2", "", "assistant-main", nil)
|
||||
buffer.CompleteStep(map[string]interface{}{"result": "72°F, Sunny"})
|
||||
|
||||
// 5. Final LLM response
|
||||
buffer.BeginStep(context.StepTypeLLM, nil, nil)
|
||||
buffer.AddAssistantMessage("text", map[string]interface{}{
|
||||
buffer.AddAssistantMessage("M4", "text", map[string]interface{}{
|
||||
"content": "The weather in San Francisco is currently 72°F and sunny.",
|
||||
}, "block-3", "", "assistant-main", nil)
|
||||
buffer.CompleteStep(nil)
|
||||
|
|
@ -998,7 +1008,7 @@ func TestBufferCompleteWorkflow(t *testing.T) {
|
|||
|
||||
// 2. LLM starts generating
|
||||
buffer.BeginStep(context.StepTypeLLM, map[string]interface{}{"model": "gpt-4"}, nil)
|
||||
buffer.AddAssistantMessage("text", map[string]interface{}{"content": "Once upon a time..."}, "block-1", "", "assistant-main", nil)
|
||||
buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Once upon a time..."}, "block-1", "", "assistant-main", nil)
|
||||
// User interrupts here!
|
||||
|
||||
// Get steps for resume
|
||||
|
|
@ -1028,13 +1038,13 @@ func TestBufferCompleteWorkflow(t *testing.T) {
|
|||
buffer.BeginStep(context.StepTypeDelegate, map[string]interface{}{"delegate_to": "assistant-child"}, childStack)
|
||||
|
||||
// Child assistant messages
|
||||
buffer.AddAssistantMessage("text", map[string]interface{}{"content": "Child assistant responding"}, "block-child", "", "assistant-child", nil)
|
||||
buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Child assistant responding"}, "block-child", "", "assistant-child", nil)
|
||||
buffer.CompleteStep(map[string]interface{}{"delegate_result": "success"})
|
||||
|
||||
// Return to main assistant
|
||||
buffer.SetAssistantID("assistant-main")
|
||||
buffer.BeginStep(context.StepTypeLLM, nil, mainStack)
|
||||
buffer.AddAssistantMessage("text", map[string]interface{}{"content": "Main assistant continuing"}, "block-main", "", "assistant-main", nil)
|
||||
buffer.AddAssistantMessage("M2", "text", map[string]interface{}{"content": "Main assistant continuing"}, "block-main", "", "assistant-main", nil)
|
||||
buffer.CompleteStep(nil)
|
||||
|
||||
// Verify
|
||||
|
|
@ -1064,6 +1074,7 @@ func TestBufferCompleteWorkflow(t *testing.T) {
|
|||
defer wg.Done()
|
||||
threadID := fmt.Sprintf("thread-%d", idx)
|
||||
buffer.AddAssistantMessage(
|
||||
fmt.Sprintf("M%d", idx),
|
||||
"text",
|
||||
map[string]interface{}{"content": fmt.Sprintf("Response from thread %d", idx)},
|
||||
"block-concurrent",
|
||||
|
|
@ -1113,9 +1124,9 @@ func TestBufferMessageSequence(t *testing.T) {
|
|||
buffer := context.NewChatBuffer("chat-mixed", "req-mixed", "assistant-mixed", "")
|
||||
|
||||
buffer.AddUserInput("Hello", "")
|
||||
buffer.AddAssistantMessage("text", nil, "", "", "", nil)
|
||||
buffer.AddAssistantMessage("M1", "text", nil, "", "", "", nil)
|
||||
buffer.AddUserInput("Follow up", "")
|
||||
buffer.AddAssistantMessage("tool_call", nil, "", "", "", nil)
|
||||
buffer.AddAssistantMessage("M2", "tool_call", nil, "", "", "", nil)
|
||||
|
||||
messages := buffer.GetMessages()
|
||||
assert.Len(t, messages, 4)
|
||||
|
|
@ -1169,3 +1180,238 @@ func TestBufferMultipleRequests(t *testing.T) {
|
|||
assert.Equal(t, "req-2", msg2.RequestID)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Streaming Message Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestBufferStreamingMessage(t *testing.T) {
|
||||
t.Run("AddStreamingMessage", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
|
||||
|
||||
buffer.AddStreamingMessage(
|
||||
"msg-stream-1",
|
||||
"text",
|
||||
map[string]interface{}{"content": "# Title\n\n"},
|
||||
"block-1",
|
||||
"thread-1",
|
||||
"assistant-1",
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, buffer.GetMessageCount())
|
||||
|
||||
// Verify streaming message is added
|
||||
msg := buffer.GetStreamingMessage("msg-stream-1")
|
||||
assert.NotNil(t, msg)
|
||||
assert.Equal(t, "msg-stream-1", msg.MessageID)
|
||||
assert.Equal(t, "text", msg.Type)
|
||||
assert.Equal(t, "# Title\n\n", msg.Props["content"])
|
||||
assert.True(t, msg.IsStreaming)
|
||||
})
|
||||
|
||||
t.Run("AppendMessageContent", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
|
||||
|
||||
// Add streaming message
|
||||
buffer.AddStreamingMessage(
|
||||
"msg-stream-2",
|
||||
"text",
|
||||
map[string]interface{}{"content": "Initial "},
|
||||
"", "", "", nil,
|
||||
)
|
||||
|
||||
// Append content
|
||||
ok := buffer.AppendMessageContent("msg-stream-2", "Line 1\n")
|
||||
assert.True(t, ok)
|
||||
|
||||
ok = buffer.AppendMessageContent("msg-stream-2", "Line 2\n")
|
||||
assert.True(t, ok)
|
||||
|
||||
// Verify accumulated content
|
||||
msg := buffer.GetStreamingMessage("msg-stream-2")
|
||||
assert.NotNil(t, msg)
|
||||
assert.Equal(t, "Initial Line 1\nLine 2\n", msg.Props["content"])
|
||||
})
|
||||
|
||||
t.Run("AppendToNonExistentMessage", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
|
||||
|
||||
// Try to append to non-existent message
|
||||
ok := buffer.AppendMessageContent("non-existent", "content")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("AppendToCompletedMessage", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
|
||||
|
||||
// Add and complete streaming message
|
||||
buffer.AddStreamingMessage(
|
||||
"msg-stream-3",
|
||||
"text",
|
||||
map[string]interface{}{"content": "Initial"},
|
||||
"", "", "", nil,
|
||||
)
|
||||
buffer.CompleteStreamingMessage("msg-stream-3")
|
||||
|
||||
// Try to append to completed message (should fail)
|
||||
ok := buffer.AppendMessageContent("msg-stream-3", " more")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("CompleteStreamingMessage", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
|
||||
|
||||
// Add streaming message
|
||||
buffer.AddStreamingMessage(
|
||||
"msg-stream-4",
|
||||
"text",
|
||||
map[string]interface{}{"content": "Hello "},
|
||||
"", "", "", nil,
|
||||
)
|
||||
|
||||
// Append content
|
||||
buffer.AppendMessageContent("msg-stream-4", "World!")
|
||||
|
||||
// Complete the message
|
||||
content, ok := buffer.CompleteStreamingMessage("msg-stream-4")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Hello World!", content)
|
||||
|
||||
// Message should no longer be streaming
|
||||
msg := buffer.GetStreamingMessage("msg-stream-4")
|
||||
assert.Nil(t, msg)
|
||||
|
||||
// But should still exist in messages
|
||||
messages := buffer.GetMessages()
|
||||
assert.Equal(t, 1, len(messages))
|
||||
assert.False(t, messages[0].IsStreaming)
|
||||
})
|
||||
|
||||
t.Run("CompleteNonExistentMessage", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
|
||||
|
||||
content, ok := buffer.CompleteStreamingMessage("non-existent")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, content)
|
||||
})
|
||||
|
||||
t.Run("StreamingMessageWorkflow", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "deepseek")
|
||||
|
||||
// Simulate a typical streaming workflow:
|
||||
// 1. SendStream sends initial content
|
||||
buffer.AddStreamingMessage(
|
||||
"msg-workflow",
|
||||
"text",
|
||||
map[string]interface{}{"content": "# Available Tests\n\n"},
|
||||
"block-main",
|
||||
"",
|
||||
"assistant-1",
|
||||
nil,
|
||||
)
|
||||
|
||||
// 2. Multiple Append calls add content
|
||||
buffer.AppendMessageContent("msg-workflow", "Send one of these keywords:\n\n")
|
||||
buffer.AppendMessageContent("msg-workflow", "- **basic** - Basic tests\n")
|
||||
buffer.AppendMessageContent("msg-workflow", "- **advanced** - Advanced tests\n")
|
||||
|
||||
// 3. End completes the message
|
||||
finalContent, ok := buffer.CompleteStreamingMessage("msg-workflow")
|
||||
assert.True(t, ok)
|
||||
|
||||
expectedContent := "# Available Tests\n\nSend one of these keywords:\n\n- **basic** - Basic tests\n- **advanced** - Advanced tests\n"
|
||||
assert.Equal(t, expectedContent, finalContent)
|
||||
|
||||
// Verify final message state
|
||||
messages := buffer.GetMessages()
|
||||
assert.Equal(t, 1, len(messages))
|
||||
assert.Equal(t, "msg-workflow", messages[0].MessageID)
|
||||
assert.Equal(t, "deepseek", messages[0].Connector) // Connector should be set
|
||||
assert.False(t, messages[0].IsStreaming)
|
||||
})
|
||||
|
||||
t.Run("MixedStreamingAndRegularMessages", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
|
||||
|
||||
// Add user input (regular)
|
||||
buffer.AddUserInput("Hello", "user1")
|
||||
|
||||
// Add streaming assistant message
|
||||
buffer.AddStreamingMessage(
|
||||
"msg-stream",
|
||||
"text",
|
||||
map[string]interface{}{"content": "Hi "},
|
||||
"", "", "", nil,
|
||||
)
|
||||
buffer.AppendMessageContent("msg-stream", "there!")
|
||||
buffer.CompleteStreamingMessage("msg-stream")
|
||||
|
||||
// Add regular assistant message
|
||||
buffer.AddAssistantMessage("M3", "text", map[string]interface{}{"content": "How can I help?"}, "", "", "", nil)
|
||||
|
||||
// Verify all messages
|
||||
messages := buffer.GetMessages()
|
||||
assert.Equal(t, 3, len(messages))
|
||||
|
||||
// Check sequence
|
||||
assert.Equal(t, 1, messages[0].Sequence)
|
||||
assert.Equal(t, 2, messages[1].Sequence)
|
||||
assert.Equal(t, 3, messages[2].Sequence)
|
||||
|
||||
// Check content
|
||||
assert.Equal(t, "user", messages[0].Role)
|
||||
assert.Equal(t, "Hi there!", messages[1].Props["content"])
|
||||
assert.Equal(t, "How can I help?", messages[2].Props["content"])
|
||||
})
|
||||
|
||||
t.Run("StreamingMessageWithEmptyInitialContent", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
|
||||
|
||||
// Add streaming message with nil props
|
||||
buffer.AddStreamingMessage(
|
||||
"msg-empty",
|
||||
"text",
|
||||
nil,
|
||||
"", "", "", nil,
|
||||
)
|
||||
|
||||
// Append content
|
||||
buffer.AppendMessageContent("msg-empty", "Content")
|
||||
|
||||
// Complete
|
||||
content, ok := buffer.CompleteStreamingMessage("msg-empty")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Content", content)
|
||||
})
|
||||
|
||||
t.Run("ConcurrentStreamingOperations", func(t *testing.T) {
|
||||
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
|
||||
|
||||
// Add streaming message
|
||||
buffer.AddStreamingMessage(
|
||||
"msg-concurrent",
|
||||
"text",
|
||||
map[string]interface{}{"content": ""},
|
||||
"", "", "", nil,
|
||||
)
|
||||
|
||||
// Concurrent appends with fixed-length content
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
buffer.AppendMessageContent("msg-concurrent", "x")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Complete
|
||||
content, ok := buffer.CompleteStreamingMessage("msg-concurrent")
|
||||
assert.True(t, ok)
|
||||
|
||||
// Content should have 100 'x' characters
|
||||
assert.Equal(t, 100, len(content))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -430,12 +430,12 @@ func (ctx *Context) BufferUserInput(messages []Message) {
|
|||
|
||||
// BufferAssistantMessage adds an assistant message to the buffer
|
||||
// Called by ctx.Send() to buffer messages for batch saving
|
||||
func (ctx *Context) BufferAssistantMessage(msgType string, props map[string]interface{}, blockID, threadID string, metadata map[string]interface{}) {
|
||||
func (ctx *Context) BufferAssistantMessage(messageID, msgType string, props map[string]interface{}, blockID, threadID string, metadata map[string]interface{}) {
|
||||
if ctx.Buffer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Buffer.AddAssistantMessage(msgType, props, blockID, threadID, ctx.AssistantID, metadata)
|
||||
ctx.Buffer.AddAssistantMessage(messageID, msgType, props, blockID, threadID, ctx.AssistantID, metadata)
|
||||
}
|
||||
|
||||
// BeginStep starts tracking a new execution step
|
||||
|
|
|
|||
|
|
@ -44,10 +44,12 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
|
|||
|
||||
// Set methods
|
||||
jsObject.Set("Send", ctx.sendMethod(v8ctx.Isolate()))
|
||||
jsObject.Set("SendStream", ctx.sendStreamMethod(v8ctx.Isolate()))
|
||||
jsObject.Set("Replace", ctx.replaceMethod(v8ctx.Isolate()))
|
||||
jsObject.Set("Append", ctx.appendMethod(v8ctx.Isolate()))
|
||||
jsObject.Set("Merge", ctx.mergeMethod(v8ctx.Isolate()))
|
||||
jsObject.Set("Set", ctx.setMethod(v8ctx.Isolate()))
|
||||
jsObject.Set("End", ctx.endMethod(v8ctx.Isolate()))
|
||||
|
||||
// Set ID generator methods
|
||||
jsObject.Set("MessageID", ctx.messageIDMethod(v8ctx.Isolate()))
|
||||
|
|
@ -266,6 +268,103 @@ func (ctx *Context) sendMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
|||
})
|
||||
}
|
||||
|
||||
// sendStreamMethod implements ctx.SendStream(message)
|
||||
// Usage: const msgId = ctx.SendStream({ type: "text", props: { content: "Initial content" } })
|
||||
// Starts a streaming message that can be appended to with ctx.Append()
|
||||
// Must be finalized with ctx.End(msgId) or ctx.End(msgId, "final content")
|
||||
// Unlike Send(), this does NOT automatically send message_end event
|
||||
// Returns: message_id (string)
|
||||
func (ctx *Context) sendStreamMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
args := info.Args()
|
||||
|
||||
if len(args) < 1 {
|
||||
return bridge.JsException(v8ctx, "SendStream requires a message argument")
|
||||
}
|
||||
|
||||
// Parse message argument
|
||||
msg, err := parseMessage(v8ctx, args[0])
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "invalid message: "+err.Error())
|
||||
}
|
||||
|
||||
// Get optional blockId argument (second argument)
|
||||
if len(args) >= 2 && args[1].IsString() && msg.BlockID == "" {
|
||||
msg.BlockID = args[1].String()
|
||||
}
|
||||
|
||||
// Call ctx.SendStream
|
||||
messageID, err := ctx.SendStream(msg)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "SendStream failed: "+err.Error())
|
||||
}
|
||||
|
||||
// Automatically flush after sending
|
||||
if err := ctx.Flush(); err != nil {
|
||||
return bridge.JsException(v8ctx, "Flush failed: "+err.Error())
|
||||
}
|
||||
|
||||
// Return the message ID
|
||||
returnID, err := v8go.NewValue(iso, messageID)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "Failed to create return value: "+err.Error())
|
||||
}
|
||||
return returnID
|
||||
})
|
||||
}
|
||||
|
||||
// endMethod implements ctx.End(messageId, finalContent?)
|
||||
// Usage: ctx.End(msgId) or ctx.End(msgId, "final content to append")
|
||||
// Finalizes a streaming message started with SendStream()
|
||||
// Sends message_end event with the complete accumulated content
|
||||
// Returns: message_id (string)
|
||||
func (ctx *Context) endMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
args := info.Args()
|
||||
|
||||
if len(args) < 1 {
|
||||
return bridge.JsException(v8ctx, "End requires a messageId argument")
|
||||
}
|
||||
|
||||
// Get message ID (first argument)
|
||||
if !args[0].IsString() {
|
||||
return bridge.JsException(v8ctx, "messageId must be a string")
|
||||
}
|
||||
messageID := args[0].String()
|
||||
|
||||
// Get optional final content (second argument)
|
||||
var finalContent string
|
||||
if len(args) >= 2 && args[1].IsString() {
|
||||
finalContent = args[1].String()
|
||||
}
|
||||
|
||||
// Call ctx.End
|
||||
var err error
|
||||
if finalContent != "" {
|
||||
err = ctx.End(messageID, finalContent)
|
||||
} else {
|
||||
err = ctx.End(messageID)
|
||||
}
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "End failed: "+err.Error())
|
||||
}
|
||||
|
||||
// Automatically flush after sending
|
||||
if err := ctx.Flush(); err != nil {
|
||||
return bridge.JsException(v8ctx, "Flush failed: "+err.Error())
|
||||
}
|
||||
|
||||
// Return the message ID
|
||||
returnID, err := v8go.NewValue(iso, messageID)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "Failed to create return value: "+err.Error())
|
||||
}
|
||||
return returnID
|
||||
})
|
||||
}
|
||||
|
||||
// replaceMethod implements ctx.Replace(messageId, message)
|
||||
// Usage: ctx.Replace(messageId, { type: "text", props: { content: "Updated content" } })
|
||||
// Replaces the entire message content with the specified message_id
|
||||
|
|
|
|||
|
|
@ -826,3 +826,518 @@ func TestJsValueEndBlock(t *testing.T) {
|
|||
output := mockWriter.buffer.String()
|
||||
assert.Contains(t, output, "block_end", "Output should contain block_end event")
|
||||
}
|
||||
|
||||
// TestJsValueSendStream tests the SendStream method on Context
|
||||
func TestJsValueSendStream(t *testing.T) {
|
||||
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Setup mock writer
|
||||
mockWriter := newMockResponseWriter()
|
||||
|
||||
// Use New() to properly initialize messageMetadata
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
// Test SendStream method
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
// Start a streaming message
|
||||
const msgId = ctx.SendStream({
|
||||
type: "text",
|
||||
props: { content: "Initial content" }
|
||||
});
|
||||
|
||||
// Verify msgId is returned
|
||||
if (typeof msgId !== 'string' || msgId === '') {
|
||||
throw new Error('SendStream should return a message ID');
|
||||
}
|
||||
|
||||
return { success: true, msgId: msgId };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", res)
|
||||
}
|
||||
if !result["success"].(bool) {
|
||||
t.Logf("Error: %v", result["error"])
|
||||
}
|
||||
assert.Equal(t, true, result["success"], "SendStream should work correctly")
|
||||
|
||||
// Verify message_start was sent but NOT message_end
|
||||
output := mockWriter.buffer.String()
|
||||
assert.Contains(t, output, "message_start", "Output should contain message_start event")
|
||||
assert.NotContains(t, output, "message_end", "Output should NOT contain message_end event (streaming)")
|
||||
}
|
||||
|
||||
// TestJsValueSendStreamWithBlockID tests SendStream with block_id parameter
|
||||
func TestJsValueSendStreamWithBlockID(t *testing.T) {
|
||||
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
mockWriter := newMockResponseWriter()
|
||||
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
// Generate block ID
|
||||
const blockId = ctx.BlockID();
|
||||
|
||||
// Start streaming with block_id
|
||||
const msgId = ctx.SendStream({
|
||||
type: "text",
|
||||
props: { content: "Streaming with block" },
|
||||
block_id: blockId
|
||||
});
|
||||
|
||||
return { success: true, msgId: msgId, blockId: blockId };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", res)
|
||||
}
|
||||
assert.Equal(t, true, result["success"], "SendStream with blockId should succeed")
|
||||
|
||||
// Verify block_start was also sent
|
||||
output := mockWriter.buffer.String()
|
||||
assert.Contains(t, output, "block_start", "Output should contain block_start event")
|
||||
}
|
||||
|
||||
// TestJsValueEnd tests the End method on Context
|
||||
func TestJsValueEnd(t *testing.T) {
|
||||
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
mockWriter := newMockResponseWriter()
|
||||
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
// Start a streaming message
|
||||
const msgId = ctx.SendStream({
|
||||
type: "text",
|
||||
props: { content: "Hello" }
|
||||
});
|
||||
|
||||
// End the message
|
||||
ctx.End(msgId);
|
||||
|
||||
return { success: true, msgId: msgId };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", res)
|
||||
}
|
||||
if !result["success"].(bool) {
|
||||
t.Logf("Error: %v", result["error"])
|
||||
}
|
||||
assert.Equal(t, true, result["success"], "End should work correctly")
|
||||
|
||||
// Verify message_end was sent
|
||||
output := mockWriter.buffer.String()
|
||||
assert.Contains(t, output, "message_end", "Output should contain message_end event after End()")
|
||||
}
|
||||
|
||||
// TestJsValueEndWithFinalContent tests End with final content parameter
|
||||
func TestJsValueEndWithFinalContent(t *testing.T) {
|
||||
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
mockWriter := newMockResponseWriter()
|
||||
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
// Start a streaming message
|
||||
const msgId = ctx.SendStream({
|
||||
type: "text",
|
||||
props: { content: "Start" }
|
||||
});
|
||||
|
||||
// End with final content
|
||||
ctx.End(msgId, " End");
|
||||
|
||||
return { success: true, msgId: msgId };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", res)
|
||||
}
|
||||
if !result["success"].(bool) {
|
||||
t.Logf("Error: %v", result["error"])
|
||||
}
|
||||
assert.Equal(t, true, result["success"], "End with final content should work correctly")
|
||||
|
||||
// Verify message_end was sent
|
||||
output := mockWriter.buffer.String()
|
||||
assert.Contains(t, output, "message_end", "Output should contain message_end event")
|
||||
}
|
||||
|
||||
// TestJsValueStreamingWorkflow tests the complete streaming workflow: SendStream -> Append -> End
|
||||
func TestJsValueStreamingWorkflow(t *testing.T) {
|
||||
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
mockWriter := newMockResponseWriter()
|
||||
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
// Start a streaming message
|
||||
const msgId = ctx.SendStream({
|
||||
type: "text",
|
||||
props: { content: "# Title\n\n" }
|
||||
});
|
||||
|
||||
// Append content in chunks (simulating streaming)
|
||||
ctx.Append(msgId, "First paragraph. ");
|
||||
ctx.Append(msgId, "Second sentence. ");
|
||||
ctx.Append(msgId, "Third sentence.\n\n");
|
||||
ctx.Append(msgId, "Second paragraph.");
|
||||
|
||||
// Finalize the message
|
||||
ctx.End(msgId);
|
||||
|
||||
return { success: true, msgId: msgId };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", res)
|
||||
}
|
||||
if !result["success"].(bool) {
|
||||
t.Logf("Error: %v", result["error"])
|
||||
}
|
||||
assert.Equal(t, true, result["success"], "Streaming workflow should work correctly")
|
||||
|
||||
// Verify the complete workflow events
|
||||
output := mockWriter.buffer.String()
|
||||
assert.Contains(t, output, "message_start", "Output should contain message_start")
|
||||
assert.Contains(t, output, "message_end", "Output should contain message_end")
|
||||
assert.Contains(t, output, "# Title", "Output should contain initial content")
|
||||
assert.Contains(t, output, "First paragraph", "Output should contain appended content")
|
||||
}
|
||||
|
||||
// TestJsValueSendStreamStringShorthand tests SendStream with string shorthand
|
||||
func TestJsValueSendStreamStringShorthand(t *testing.T) {
|
||||
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
mockWriter := newMockResponseWriter()
|
||||
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
// SendStream with string shorthand
|
||||
const msgId = ctx.SendStream("Hello streaming");
|
||||
|
||||
if (typeof msgId !== 'string' || msgId === '') {
|
||||
throw new Error('SendStream should return a message ID');
|
||||
}
|
||||
|
||||
ctx.End(msgId);
|
||||
|
||||
return { success: true, msgId: msgId };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", res)
|
||||
}
|
||||
assert.Equal(t, true, result["success"], "SendStream with string shorthand should succeed")
|
||||
}
|
||||
|
||||
// TestJsValueEndErrorHandling tests error handling in End method
|
||||
func TestJsValueEndErrorHandling(t *testing.T) {
|
||||
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
mockWriter := newMockResponseWriter()
|
||||
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
// Test End without arguments
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
ctx.End();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", res)
|
||||
}
|
||||
assert.Equal(t, false, result["success"], "End without arguments should fail")
|
||||
assert.Contains(t, result["error"], "messageId", "Error should mention missing messageId")
|
||||
}
|
||||
|
||||
// TestJsValueEndWithInvalidMessageID tests End with invalid messageId type
|
||||
func TestJsValueEndWithInvalidMessageID(t *testing.T) {
|
||||
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
mockWriter := newMockResponseWriter()
|
||||
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
// Test End with non-string messageId
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
ctx.End(123);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", res)
|
||||
}
|
||||
assert.Equal(t, false, result["success"], "End with non-string messageId should fail")
|
||||
assert.Contains(t, result["error"], "string", "Error should mention messageId must be string")
|
||||
}
|
||||
|
||||
// TestJsValueSendStreamErrorHandling tests error handling in SendStream method
|
||||
func TestJsValueSendStreamErrorHandling(t *testing.T) {
|
||||
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
mockWriter := newMockResponseWriter()
|
||||
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
// Test SendStream without arguments
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
ctx.SendStream();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", res)
|
||||
}
|
||||
assert.Equal(t, false, result["success"], "SendStream without arguments should fail")
|
||||
assert.Contains(t, result["error"], "SendStream requires a message argument", "Error should mention missing message")
|
||||
}
|
||||
|
||||
// TestJsValueMultipleStreams tests handling multiple concurrent streaming messages
|
||||
func TestJsValueMultipleStreams(t *testing.T) {
|
||||
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
mockWriter := newMockResponseWriter()
|
||||
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
res, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
try {
|
||||
// Start multiple streaming messages
|
||||
const msg1 = ctx.SendStream({ type: "text", props: { content: "Stream 1: " } });
|
||||
const msg2 = ctx.SendStream({ type: "text", props: { content: "Stream 2: " } });
|
||||
|
||||
// Interleave appends
|
||||
ctx.Append(msg1, "A");
|
||||
ctx.Append(msg2, "X");
|
||||
ctx.Append(msg1, "B");
|
||||
ctx.Append(msg2, "Y");
|
||||
ctx.Append(msg1, "C");
|
||||
ctx.Append(msg2, "Z");
|
||||
|
||||
// End both streams
|
||||
ctx.End(msg1);
|
||||
ctx.End(msg2);
|
||||
|
||||
return { success: true, msg1: msg1, msg2: msg2 };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
result, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", res)
|
||||
}
|
||||
if !result["success"].(bool) {
|
||||
t.Logf("Error: %v", result["error"])
|
||||
}
|
||||
assert.Equal(t, true, result["success"], "Multiple streams should work correctly")
|
||||
assert.NotEqual(t, result["msg1"], result["msg2"], "Message IDs should be different")
|
||||
}
|
||||
|
||||
// TestJsValueSendVsSendStream tests the difference between Send and SendStream
|
||||
func TestJsValueSendVsSendStream(t *testing.T) {
|
||||
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Test Send - should auto-send message_end
|
||||
t.Run("Send auto-ends", func(t *testing.T) {
|
||||
mockWriter := newMockResponseWriter()
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
_, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
ctx.Send("Complete message");
|
||||
return true;
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
output := mockWriter.buffer.String()
|
||||
assert.Contains(t, output, "message_start", "Send should emit message_start")
|
||||
assert.Contains(t, output, "message_end", "Send should auto-emit message_end")
|
||||
})
|
||||
|
||||
// Test SendStream - should NOT auto-send message_end
|
||||
t.Run("SendStream requires explicit End", func(t *testing.T) {
|
||||
mockWriter := newMockResponseWriter()
|
||||
cxt := New(context.Background(), nil, "test-chat-id")
|
||||
cxt.AssistantID = "test-assistant-id"
|
||||
cxt.Accept = AcceptWebCUI
|
||||
cxt.Locale = "en"
|
||||
cxt.Writer = mockWriter
|
||||
|
||||
_, err := v8.Call(v8.CallOptions{}, `
|
||||
function test(ctx) {
|
||||
const msgId = ctx.SendStream("Streaming message");
|
||||
// Intentionally NOT calling ctx.End(msgId)
|
||||
return msgId;
|
||||
}`, cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("Call failed: %v", err)
|
||||
}
|
||||
|
||||
output := mockWriter.buffer.String()
|
||||
assert.Contains(t, output, "message_start", "SendStream should emit message_start")
|
||||
assert.NotContains(t, output, "message_end", "SendStream should NOT auto-emit message_end")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,13 @@ func (ctx *Context) Send(msg *message.Message) error {
|
|||
|
||||
// Increment chunk count for this message
|
||||
metadata.ChunkCount++
|
||||
|
||||
// Update Buffer content for streaming messages (for storage)
|
||||
if ctx.Buffer != nil && msg.Props != nil {
|
||||
if content, ok := msg.Props["content"].(string); ok {
|
||||
ctx.Buffer.AppendMessageContent(msg.MessageID, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +163,7 @@ func (ctx *Context) Send(msg *message.Message) error {
|
|||
assistantID = ctx.Stack.AssistantID
|
||||
}
|
||||
ctx.Buffer.AddAssistantMessage(
|
||||
msg.MessageID, // Use the same MessageID as sent to client
|
||||
msg.Type,
|
||||
msg.Props,
|
||||
msg.BlockID,
|
||||
|
|
@ -207,6 +215,189 @@ func (ctx *Context) Send(msg *message.Message) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// SendStream sends a streaming message that can be appended to later
|
||||
// Unlike Send(), this does NOT automatically send message_end event
|
||||
// Use ctx.Append() to add content, then ctx.End() to finalize
|
||||
// Returns the message ID for use with Append/End
|
||||
func (ctx *Context) SendStream(msg *message.Message) (string, error) {
|
||||
out, err := ctx.getOutput()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Skip lifecycle events for event-type messages
|
||||
isEventMessage := msg.Type == message.TypeEvent
|
||||
if isEventMessage {
|
||||
// Event messages should use Send(), not SendStream()
|
||||
return "", ctx.Send(msg)
|
||||
}
|
||||
|
||||
// === Auto-generate ChunkID ===
|
||||
if msg.ChunkID == "" {
|
||||
if ctx.IDGenerator != nil {
|
||||
msg.ChunkID = ctx.IDGenerator.GenerateChunkID()
|
||||
} else {
|
||||
msg.ChunkID = message.GenerateNanoID()
|
||||
}
|
||||
}
|
||||
|
||||
// === Auto-set ThreadID for non-root Stack ===
|
||||
if msg.ThreadID == "" && ctx.Stack != nil && !ctx.Stack.IsRoot() {
|
||||
msg.ThreadID = ctx.Stack.ID
|
||||
}
|
||||
|
||||
// === Handle BlockID and block_start event ===
|
||||
if msg.BlockID != "" && ctx.messageMetadata != nil {
|
||||
if ctx.messageMetadata.getBlock(msg.BlockID) == nil {
|
||||
blockStartData := message.EventBlockStartData{
|
||||
BlockID: msg.BlockID,
|
||||
Type: "mixed",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
blockStartEvent := output.NewEventMessage(message.EventBlockStart, "Block started", blockStartData)
|
||||
if err := ctx.sendRaw(blockStartEvent); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ctx.messageMetadata.setBlock(msg.BlockID, &BlockMetadata{
|
||||
BlockID: msg.BlockID,
|
||||
Type: "mixed",
|
||||
StartTime: time.Now(),
|
||||
MessageCount: 0,
|
||||
})
|
||||
}
|
||||
ctx.messageMetadata.updateBlock(msg.BlockID, func(block *BlockMetadata) {
|
||||
block.MessageCount++
|
||||
})
|
||||
}
|
||||
|
||||
// === Generate MessageID if not provided ===
|
||||
if msg.MessageID == "" {
|
||||
if ctx.IDGenerator != nil {
|
||||
msg.MessageID = ctx.IDGenerator.GenerateMessageID()
|
||||
} else {
|
||||
msg.MessageID = message.GenerateNanoID()
|
||||
}
|
||||
}
|
||||
|
||||
// === Send message_start event ===
|
||||
messageStartData := message.EventMessageStartData{
|
||||
MessageID: msg.MessageID,
|
||||
Type: msg.Type,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
ThreadID: msg.ThreadID,
|
||||
}
|
||||
messageStartEvent := output.NewEventMessage(message.EventMessageStart, "Message started", messageStartData)
|
||||
if err := ctx.sendRaw(messageStartEvent); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// === Record message metadata ===
|
||||
if ctx.messageMetadata != nil {
|
||||
ctx.messageMetadata.setMessage(msg.MessageID, &MessageMetadata{
|
||||
MessageID: msg.MessageID,
|
||||
BlockID: msg.BlockID,
|
||||
ThreadID: msg.ThreadID,
|
||||
Type: msg.Type,
|
||||
StartTime: time.Now(),
|
||||
ChunkCount: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// === Actually send the message ===
|
||||
if err := out.Send(msg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// === Buffer streaming message (will be completed by End()) ===
|
||||
if ctx.Buffer != nil && !ctx.shouldSkipHistory() {
|
||||
assistantID := ""
|
||||
if ctx.Stack != nil {
|
||||
assistantID = ctx.Stack.AssistantID
|
||||
}
|
||||
ctx.Buffer.AddStreamingMessage(
|
||||
msg.MessageID,
|
||||
msg.Type,
|
||||
msg.Props,
|
||||
msg.BlockID,
|
||||
msg.ThreadID,
|
||||
assistantID,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// NOTE: No message_end event here - will be sent by End()
|
||||
return msg.MessageID, nil
|
||||
}
|
||||
|
||||
// End finalizes a streaming message started with SendStream
|
||||
// Optionally appends final content before sending message_end event
|
||||
// This also saves the complete message to the buffer for storage
|
||||
func (ctx *Context) End(messageID string, finalContent ...string) error {
|
||||
if messageID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Append final content if provided
|
||||
if len(finalContent) > 0 && finalContent[0] != "" {
|
||||
// Create a delta message for the final content
|
||||
deltaMsg := &message.Message{
|
||||
MessageID: messageID,
|
||||
Type: message.TypeText,
|
||||
Delta: true,
|
||||
DeltaAction: message.DeltaAppend,
|
||||
Props: map[string]interface{}{
|
||||
"content": finalContent[0],
|
||||
},
|
||||
}
|
||||
if err := ctx.Send(deltaMsg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Get complete content from buffer
|
||||
var completeContent string
|
||||
if ctx.Buffer != nil {
|
||||
completeContent, _ = ctx.Buffer.CompleteStreamingMessage(messageID)
|
||||
}
|
||||
|
||||
// Get metadata for duration calculation
|
||||
var durationMs int64
|
||||
var threadID string
|
||||
var chunkCount int
|
||||
var msgType string = message.TypeText
|
||||
|
||||
if ctx.messageMetadata != nil {
|
||||
if metadata := ctx.messageMetadata.getMessage(messageID); metadata != nil {
|
||||
durationMs = time.Since(metadata.StartTime).Milliseconds()
|
||||
threadID = metadata.ThreadID
|
||||
chunkCount = metadata.ChunkCount
|
||||
msgType = metadata.Type
|
||||
}
|
||||
}
|
||||
|
||||
// Build message_end event data
|
||||
endData := message.EventMessageEndData{
|
||||
MessageID: messageID,
|
||||
Type: msgType,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
ThreadID: threadID,
|
||||
DurationMs: durationMs,
|
||||
ChunkCount: chunkCount,
|
||||
Status: "completed",
|
||||
}
|
||||
|
||||
// Add complete content to extra
|
||||
if completeContent != "" {
|
||||
endData.Extra = map[string]interface{}{
|
||||
"content": completeContent,
|
||||
}
|
||||
}
|
||||
|
||||
// Send message_end event
|
||||
messageEndEvent := output.NewEventMessage(message.EventMessageEnd, "Message completed", endData)
|
||||
return ctx.sendRaw(messageEndEvent)
|
||||
}
|
||||
|
||||
// EndMessage sends a message_end event for a completed message
|
||||
// Note: For non-delta messages, message_end is automatically sent by Send()
|
||||
// This method is primarily for delta streaming scenarios:
|
||||
|
|
|
|||
|
|
@ -141,34 +141,35 @@ Stores user-visible messages (both user input and assistant responses).
|
|||
|
||||
**Table Name:** `agent_message`
|
||||
|
||||
| Column | Type | Nullable | Index | Description |
|
||||
| -------------- | ----------- | -------- | ------ | ----------------------------------------- |
|
||||
| `id` | ID | No | PK | Auto-increment primary key |
|
||||
| `message_id` | string(64) | No | Unique | Unique message identifier |
|
||||
| `chat_id` | string(64) | No | Yes | Parent chat ID |
|
||||
| `request_id` | string(64) | Yes | Yes | Request ID for grouping |
|
||||
| `role` | enum | No | Yes | Role: `user`, `assistant` |
|
||||
| `type` | string(50) | No | - | Message type (text, image, loading, etc.) |
|
||||
| `props` | json | No | - | Message properties (content, url, etc.) |
|
||||
| `block_id` | string(64) | Yes | Yes | Block grouping ID |
|
||||
| `thread_id` | string(64) | Yes | Yes | Thread grouping ID |
|
||||
| `assistant_id` | string(200) | Yes | Yes | Assistant ID (join to get name/avatar) |
|
||||
| `connector` | string(200) | Yes | Yes | Connector ID used for this message |
|
||||
| `sequence` | integer | No | - | Message order within chat (in composite) |
|
||||
| `metadata` | json | Yes | - | Additional metadata |
|
||||
| `created_at` | timestamp | No | Yes | Creation timestamp |
|
||||
| `updated_at` | timestamp | No | - | Last update timestamp |
|
||||
| Column | Type | Nullable | Index | Description |
|
||||
| -------------- | ----------- | -------- | ----- | ------------------------------------------ |
|
||||
| `id` | ID | No | PK | Auto-increment primary key |
|
||||
| `message_id` | string(64) | No | - | Message identifier (unique within request) |
|
||||
| `chat_id` | string(64) | No | Yes | Parent chat ID |
|
||||
| `request_id` | string(64) | Yes | Yes | Request ID for grouping |
|
||||
| `role` | enum | No | Yes | Role: `user`, `assistant` |
|
||||
| `type` | string(50) | No | - | Message type (text, image, loading, etc.) |
|
||||
| `props` | json | No | - | Message properties (content, url, etc.) |
|
||||
| `block_id` | string(64) | Yes | Yes | Block grouping ID |
|
||||
| `thread_id` | string(64) | Yes | Yes | Thread grouping ID |
|
||||
| `assistant_id` | string(200) | Yes | Yes | Assistant ID (join to get name/avatar) |
|
||||
| `connector` | string(200) | Yes | Yes | Connector ID used for this message |
|
||||
| `sequence` | integer | No | - | Message order within chat (in composite) |
|
||||
| `metadata` | json | Yes | - | Additional metadata |
|
||||
| `created_at` | timestamp | No | Yes | Creation timestamp |
|
||||
| `updated_at` | timestamp | No | - | Last update timestamp |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
| Name | Columns | Type |
|
||||
| ------------------- | --------------------- | ----- |
|
||||
| `idx_msg_chat_seq` | `chat_id`, `sequence` | index |
|
||||
| `idx_msg_request` | `request_id` | index |
|
||||
| `idx_msg_role` | `role` | index |
|
||||
| `idx_msg_block` | `block_id` | index |
|
||||
| `idx_msg_thread` | `thread_id` | index |
|
||||
| `idx_msg_assistant` | `assistant_id` | index |
|
||||
| Name | Columns | Type |
|
||||
| ------------------------- | -------------------------- | ------ |
|
||||
| `idx_msg_chat_seq` | `chat_id`, `sequence` | index |
|
||||
| `idx_msg_request_message` | `request_id`, `message_id` | unique |
|
||||
| `idx_msg_request` | `request_id` | index |
|
||||
| `idx_msg_role` | `role` | index |
|
||||
| `idx_msg_block` | `block_id` | index |
|
||||
| `idx_msg_thread` | `thread_id` | index |
|
||||
| `idx_msg_assistant` | `assistant_id` | index |
|
||||
|
||||
**Message Types:**
|
||||
|
||||
|
|
|
|||
286
data/bindata.go
286
data/bindata.go
File diff suppressed because it is too large
Load diff
|
|
@ -18,10 +18,9 @@
|
|||
"name": "message_id",
|
||||
"type": "string",
|
||||
"label": "Message ID",
|
||||
"comment": "Unique message identifier",
|
||||
"comment": "Message identifier (unique within request)",
|
||||
"length": 64,
|
||||
"nullable": false,
|
||||
"unique": true
|
||||
"nullable": false
|
||||
},
|
||||
{
|
||||
"name": "chat_id",
|
||||
|
|
@ -136,6 +135,12 @@
|
|||
"columns": ["chat_id", "sequence"],
|
||||
"type": "index",
|
||||
"comment": "Index for message ordering within chat"
|
||||
},
|
||||
{
|
||||
"name": "idx_msg_request_message",
|
||||
"columns": ["request_id", "message_id"],
|
||||
"type": "unique",
|
||||
"comment": "Unique constraint for message_id within request"
|
||||
}
|
||||
],
|
||||
"option": { "timestamps": true, "soft_deletes": true }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue