Implement missing RFC chat backend features
This commit is contained in:
parent
4f1863f9eb
commit
c38711ec7d
2 changed files with 622 additions and 10 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -34,6 +35,7 @@ type ChatSettings struct {
|
|||
}
|
||||
|
||||
type ImageAttachment struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Data string `json:"data"`
|
||||
|
|
@ -68,13 +70,15 @@ type ThinkingState struct {
|
|||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Attachments []ImageAttachment `json:"attachments,omitempty"`
|
||||
Thinking *ThinkingState `json:"thinking,omitempty"`
|
||||
ToolCalls []ToolInvocation `json:"tool_calls,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Streaming bool `json:"streaming,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
Attachments []ImageAttachment `json:"attachments,omitempty"`
|
||||
Thinking *ThinkingState `json:"thinking,omitempty"`
|
||||
ToolCalls []ToolInvocation `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
|
|
@ -219,8 +223,21 @@ func (s *ChatStore) Load(cfg *config.Config) {
|
|||
if n := parseCounter(msg.ID); n > s.nextID {
|
||||
s.nextID = n
|
||||
}
|
||||
for _, attachment := range msg.Attachments {
|
||||
if n := parseCounter(attachment.ID); n > s.nextID {
|
||||
s.nextID = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, attachments := range s.queuedImages {
|
||||
for _, attachment := range attachments {
|
||||
if n := parseCounter(attachment.ID); n > s.nextID {
|
||||
s.nextID = n
|
||||
}
|
||||
}
|
||||
}
|
||||
s.ensureAttachmentIdentifiersLocked()
|
||||
}
|
||||
|
||||
func (s *ChatStore) Snapshot() ChatSnapshot {
|
||||
|
|
@ -404,7 +421,7 @@ func (s *ChatStore) History(conversationID string) ([]ChatMessage, error) {
|
|||
if !ok {
|
||||
return nil, coreerr.E("display.chat.History", "conversation not found: "+conversationID, nil)
|
||||
}
|
||||
return append([]ChatMessage(nil), conv.Messages...), nil
|
||||
return cloneMessages(conv.Messages), nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) ClearConversation(conversationID string) (Conversation, error) {
|
||||
|
|
@ -432,10 +449,45 @@ func (s *ChatStore) QueueImage(conversationID string, attachment ImageAttachment
|
|||
if _, ok := s.conversations[conversationID]; !ok {
|
||||
return nil, coreerr.E("display.chat.QueueImage", "conversation not found: "+conversationID, nil)
|
||||
}
|
||||
if attachment.ID == "" {
|
||||
attachment.ID = s.nextIdentifier("img")
|
||||
}
|
||||
s.queuedImages[conversationID] = append(s.queuedImages[conversationID], attachment)
|
||||
return append([]ImageAttachment(nil), s.queuedImages[conversationID]...), nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) RemoveQueuedImage(conversationID, attachmentID string) ([]ImageAttachment, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.conversations[conversationID]; !ok {
|
||||
return nil, coreerr.E("display.chat.RemoveQueuedImage", "conversation not found: "+conversationID, nil)
|
||||
}
|
||||
if attachmentID == "" {
|
||||
return nil, coreerr.E("display.chat.RemoveQueuedImage", "attachment id is required", nil)
|
||||
}
|
||||
|
||||
attachments := s.queuedImages[conversationID]
|
||||
filtered := attachments[:0]
|
||||
removed := false
|
||||
for _, attachment := range attachments {
|
||||
if attachment.ID == attachmentID {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, attachment)
|
||||
}
|
||||
if !removed {
|
||||
return nil, coreerr.E("display.chat.RemoveQueuedImage", "attachment not found: "+attachmentID, nil)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
delete(s.queuedImages, conversationID)
|
||||
return []ImageAttachment{}, nil
|
||||
}
|
||||
s.queuedImages[conversationID] = append([]ImageAttachment(nil), filtered...)
|
||||
return append([]ImageAttachment(nil), s.queuedImages[conversationID]...), nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, ChatMessage, ChatMessage, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -461,6 +513,7 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C
|
|||
Role: "assistant",
|
||||
Content: buildAssistantPlaceholder(conv.Model, content),
|
||||
CreatedAt: now.Add(250 * time.Millisecond),
|
||||
Streaming: false,
|
||||
}
|
||||
if thinking, ok := s.thinking[conversationID]; ok && strings.TrimSpace(thinking.Content) != "" {
|
||||
copyThinking := thinking
|
||||
|
|
@ -485,6 +538,106 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C
|
|||
return cloneConversation(conv), userMessage, assistantMessage, nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) StartStreaming(conversationID string) (Conversation, ChatMessage, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
conv, ok := s.conversations[conversationID]
|
||||
if !ok {
|
||||
return Conversation{}, ChatMessage{}, coreerr.E("display.chat.StartStreaming", "conversation not found: "+conversationID, nil)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var assistantMessage *ChatMessage
|
||||
if canReuseAssistantForStreaming(conv) {
|
||||
assistantMessage = &conv.Messages[len(conv.Messages)-1]
|
||||
assistantMessage.Content = ""
|
||||
assistantMessage.Streaming = true
|
||||
assistantMessage.FinishReason = ""
|
||||
if thinking, ok := s.thinking[conversationID]; ok {
|
||||
copyThinking := thinking
|
||||
assistantMessage.Thinking = ©Thinking
|
||||
}
|
||||
} else {
|
||||
message := ChatMessage{
|
||||
ID: s.nextIdentifier("msg"),
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
CreatedAt: now,
|
||||
Streaming: true,
|
||||
}
|
||||
if thinking, ok := s.thinking[conversationID]; ok {
|
||||
copyThinking := thinking
|
||||
message.Thinking = ©Thinking
|
||||
}
|
||||
conv.Messages = append(conv.Messages, message)
|
||||
assistantMessage = &conv.Messages[len(conv.Messages)-1]
|
||||
}
|
||||
|
||||
s.streamingMessage[conversationID] = assistantMessage.Content
|
||||
conv.UpdatedAt = now
|
||||
s.conversations[conversationID] = conv
|
||||
return cloneConversation(conv), cloneMessage(*assistantMessage), nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) AppendStreaming(conversationID, content string) (Conversation, ChatMessage, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
conv, ok := s.conversations[conversationID]
|
||||
if !ok {
|
||||
return Conversation{}, ChatMessage{}, coreerr.E("display.chat.AppendStreaming", "conversation not found: "+conversationID, nil)
|
||||
}
|
||||
|
||||
index := latestStreamingAssistantIndex(conv.Messages)
|
||||
if index < 0 {
|
||||
return Conversation{}, ChatMessage{}, coreerr.E("display.chat.AppendStreaming", "streaming assistant message not found: "+conversationID, nil)
|
||||
}
|
||||
|
||||
conv.Messages[index].Content += content
|
||||
if thinking, ok := s.thinking[conversationID]; ok {
|
||||
copyThinking := thinking
|
||||
conv.Messages[index].Thinking = ©Thinking
|
||||
}
|
||||
s.streamingMessage[conversationID] = conv.Messages[index].Content
|
||||
conv.UpdatedAt = time.Now().UTC()
|
||||
s.conversations[conversationID] = conv
|
||||
return cloneConversation(conv), cloneMessage(conv.Messages[index]), nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) FinishStreaming(conversationID, finishReason string) (Conversation, ChatMessage, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
conv, ok := s.conversations[conversationID]
|
||||
if !ok {
|
||||
return Conversation{}, ChatMessage{}, coreerr.E("display.chat.FinishStreaming", "conversation not found: "+conversationID, nil)
|
||||
}
|
||||
|
||||
index := latestStreamingAssistantIndex(conv.Messages)
|
||||
if index < 0 {
|
||||
return Conversation{}, ChatMessage{}, coreerr.E("display.chat.FinishStreaming", "streaming assistant message not found: "+conversationID, nil)
|
||||
}
|
||||
|
||||
conv.Messages[index].Streaming = false
|
||||
conv.Messages[index].FinishReason = strings.TrimSpace(finishReason)
|
||||
if thinking, ok := s.thinking[conversationID]; ok {
|
||||
copyThinking := thinking
|
||||
if copyThinking.Active {
|
||||
copyThinking.Active = false
|
||||
if copyThinking.FinishedAt.IsZero() {
|
||||
copyThinking.FinishedAt = time.Now().UTC()
|
||||
}
|
||||
s.thinking[conversationID] = copyThinking
|
||||
}
|
||||
conv.Messages[index].Thinking = ©Thinking
|
||||
}
|
||||
delete(s.streamingMessage, conversationID)
|
||||
conv.UpdatedAt = time.Now().UTC()
|
||||
s.conversations[conversationID] = conv
|
||||
return cloneConversation(conv), cloneMessage(conv.Messages[index]), nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) StartThinking(conversationID string) (ThinkingState, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -497,6 +650,7 @@ func (s *ChatStore) StartThinking(conversationID string) (ThinkingState, error)
|
|||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
s.thinking[conversationID] = state
|
||||
s.syncThinkingToStreamingMessageLocked(conversationID, state)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
|
|
@ -511,6 +665,7 @@ func (s *ChatStore) AppendThinking(conversationID, content string) (ThinkingStat
|
|||
state.Active = true
|
||||
state.Content += content
|
||||
s.thinking[conversationID] = state
|
||||
s.syncThinkingToStreamingMessageLocked(conversationID, state)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
|
|
@ -525,6 +680,7 @@ func (s *ChatStore) EndThinking(conversationID string) (ThinkingState, error) {
|
|||
state.Active = false
|
||||
state.FinishedAt = time.Now().UTC()
|
||||
s.thinking[conversationID] = state
|
||||
s.syncThinkingToStreamingMessageLocked(conversationID, state)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
|
|
@ -552,6 +708,103 @@ func (s *ChatStore) RecordToolResult(conversationID string, call ToolCall, resul
|
|||
return cloneConversation(conv), nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) RenameConversation(id, title string) (Conversation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
conv, ok := s.conversations[id]
|
||||
if !ok {
|
||||
return Conversation{}, coreerr.E("display.chat.RenameConversation", "conversation not found: "+id, nil)
|
||||
}
|
||||
|
||||
cleanTitle := strings.TrimSpace(title)
|
||||
if cleanTitle == "" {
|
||||
cleanTitle = "Untitled conversation"
|
||||
}
|
||||
conv.Title = cleanTitle
|
||||
conv.UpdatedAt = time.Now().UTC()
|
||||
s.conversations[id] = conv
|
||||
return cloneConversation(conv), nil
|
||||
}
|
||||
|
||||
func (s *ChatStore) ExportConversationMarkdown(id string) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
conv, ok := s.conversations[id]
|
||||
if !ok {
|
||||
return "", coreerr.E("display.chat.ExportConversationMarkdown", "conversation not found: "+id, nil)
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString("# ")
|
||||
builder.WriteString(conv.Title)
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString("- Conversation ID: ")
|
||||
builder.WriteString(conv.ID)
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString("- Model: ")
|
||||
builder.WriteString(conv.Model)
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString("- Updated: ")
|
||||
builder.WriteString(conv.UpdatedAt.UTC().Format(time.RFC3339))
|
||||
builder.WriteString("\n")
|
||||
|
||||
for _, message := range conv.Messages {
|
||||
builder.WriteString("\n## ")
|
||||
builder.WriteString(roleHeading(message.Role))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(message.Content)
|
||||
builder.WriteString("\n")
|
||||
|
||||
if len(message.Attachments) > 0 {
|
||||
builder.WriteString("\n### Attachments\n")
|
||||
for _, attachment := range message.Attachments {
|
||||
builder.WriteString("- ")
|
||||
builder.WriteString(attachment.Filename)
|
||||
builder.WriteString(" (")
|
||||
builder.WriteString(attachment.MimeType)
|
||||
builder.WriteString(")\n")
|
||||
}
|
||||
}
|
||||
|
||||
if message.Thinking != nil && strings.TrimSpace(message.Thinking.Content) != "" {
|
||||
builder.WriteString("\n### Thinking\n\n")
|
||||
builder.WriteString(message.Thinking.Content)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(message.ToolCalls) > 0 {
|
||||
builder.WriteString("\n### Tool Calls\n")
|
||||
for _, invocation := range message.ToolCalls {
|
||||
builder.WriteString("\n#### ")
|
||||
builder.WriteString(invocation.Call.Name)
|
||||
builder.WriteString("\n\n")
|
||||
if len(invocation.Call.Arguments) > 0 {
|
||||
arguments, err := json.MarshalIndent(invocation.Call.Arguments, "", " ")
|
||||
if err == nil {
|
||||
builder.WriteString("```json\n")
|
||||
builder.Write(arguments)
|
||||
builder.WriteString("\n```\n")
|
||||
}
|
||||
}
|
||||
if invocation.Result.Content != "" {
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(invocation.Result.Content)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if invocation.Error != "" {
|
||||
builder.WriteString("\nError: ")
|
||||
builder.WriteString(invocation.Error)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
type QueryChatHistory struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
}
|
||||
|
|
@ -589,17 +842,31 @@ type QueryConversationsSearch struct {
|
|||
Query string `json:"q"`
|
||||
}
|
||||
|
||||
type QueryConversationExport struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type TaskConversationDelete struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type TaskConversationNew struct{}
|
||||
|
||||
type TaskConversationRename struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type TaskAttachImage struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Attachment ImageAttachment `json:"attachment"`
|
||||
}
|
||||
|
||||
type TaskDetachImage struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
}
|
||||
|
||||
type TaskThinkingStart struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
}
|
||||
|
|
@ -620,6 +887,20 @@ type TaskRecordToolCall struct {
|
|||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type TaskChatStreamStart struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
}
|
||||
|
||||
type TaskChatStreamAppend struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type TaskChatStreamFinish struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) handleChatQuery(_ *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q := q.(type) {
|
||||
case QueryChatHistory:
|
||||
|
|
@ -639,6 +920,9 @@ func (s *Service) handleChatQuery(_ *core.Core, q core.Query) (any, bool, error)
|
|||
return conv, true, nil
|
||||
case QueryConversationsSearch:
|
||||
return s.chat.SearchConversations(q.Query), true, nil
|
||||
case QueryConversationExport:
|
||||
content, err := s.chat.ExportConversationMarkdown(q.ID)
|
||||
return content, true, err
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
|
@ -649,6 +933,21 @@ func (s *Service) handleChatTask(_ *core.Core, t core.Task) (any, bool, error) {
|
|||
case TaskConversationNew:
|
||||
conv := s.chat.NewConversation()
|
||||
return conv, true, s.chat.persist(s.configFile)
|
||||
case TaskConversationRename:
|
||||
conv, err := s.chat.RenameConversation(t.ID, t.Title)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{
|
||||
Type: "chat.conversation.renamed",
|
||||
Data: map[string]any{
|
||||
"conversation_id": conv.ID,
|
||||
"title": conv.Title,
|
||||
},
|
||||
})
|
||||
}
|
||||
return conv, true, s.chat.persist(s.configFile)
|
||||
case TaskChatSend:
|
||||
conv, userMessage, assistantMessage, err := s.chat.SendMessage(t.ConversationID, t.Content)
|
||||
if err != nil {
|
||||
|
|
@ -694,6 +993,30 @@ func (s *Service) handleChatTask(_ *core.Core, t core.Task) (any, bool, error) {
|
|||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{
|
||||
Type: "chat.attachments.updated",
|
||||
Data: map[string]any{
|
||||
"conversation_id": t.ConversationID,
|
||||
"attachments": attachments,
|
||||
},
|
||||
})
|
||||
}
|
||||
return attachments, true, s.chat.persist(s.configFile)
|
||||
case TaskDetachImage:
|
||||
attachments, err := s.chat.RemoveQueuedImage(t.ConversationID, t.AttachmentID)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{
|
||||
Type: "chat.attachments.updated",
|
||||
Data: map[string]any{
|
||||
"conversation_id": t.ConversationID,
|
||||
"attachments": attachments,
|
||||
},
|
||||
})
|
||||
}
|
||||
return attachments, true, s.chat.persist(s.configFile)
|
||||
case TaskThinkingStart:
|
||||
state, err := s.chat.StartThinking(t.ConversationID)
|
||||
|
|
@ -719,6 +1042,53 @@ func (s *Service) handleChatTask(_ *core.Core, t core.Task) (any, bool, error) {
|
|||
return nil, true, err
|
||||
}
|
||||
return conv, true, s.chat.persist(s.configFile)
|
||||
case TaskChatStreamStart:
|
||||
conv, message, err := s.chat.StartStreaming(t.ConversationID)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{
|
||||
Type: "chat.stream.start",
|
||||
Data: map[string]any{
|
||||
"conversation_id": t.ConversationID,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
return conv, true, s.chat.persist(s.configFile)
|
||||
case TaskChatStreamAppend:
|
||||
conv, message, err := s.chat.AppendStreaming(t.ConversationID, t.Content)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{
|
||||
Type: "chat.stream.delta",
|
||||
Data: map[string]any{
|
||||
"conversation_id": t.ConversationID,
|
||||
"message": message,
|
||||
"delta": t.Content,
|
||||
},
|
||||
})
|
||||
}
|
||||
return conv, true, s.chat.persist(s.configFile)
|
||||
case TaskChatStreamFinish:
|
||||
conv, message, err := s.chat.FinishStreaming(t.ConversationID, t.FinishReason)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{
|
||||
Type: "chat.stream.end",
|
||||
Data: map[string]any{
|
||||
"conversation_id": t.ConversationID,
|
||||
"message": message,
|
||||
"finish_reason": t.FinishReason,
|
||||
},
|
||||
})
|
||||
}
|
||||
return conv, true, s.chat.persist(s.configFile)
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
|
@ -748,6 +1118,35 @@ func buildAssistantPlaceholder(model, prompt string) string {
|
|||
"Captured your prompt for " + model + " and stored it in chat history."
|
||||
}
|
||||
|
||||
func roleHeading(role string) string {
|
||||
if role == "" {
|
||||
return "Message"
|
||||
}
|
||||
lower := strings.ToLower(role)
|
||||
return strings.ToUpper(lower[:1]) + lower[1:]
|
||||
}
|
||||
|
||||
func canReuseAssistantForStreaming(conv Conversation) bool {
|
||||
if len(conv.Messages) == 0 {
|
||||
return false
|
||||
}
|
||||
last := conv.Messages[len(conv.Messages)-1]
|
||||
if last.Role != "assistant" {
|
||||
return false
|
||||
}
|
||||
if last.Streaming {
|
||||
return true
|
||||
}
|
||||
if len(conv.Messages) < 2 {
|
||||
return false
|
||||
}
|
||||
previous := conv.Messages[len(conv.Messages)-2]
|
||||
if previous.Role != "user" {
|
||||
return false
|
||||
}
|
||||
return last.Content == buildAssistantPlaceholder(conv.Model, previous.Content)
|
||||
}
|
||||
|
||||
func parseCounter(value string) uint64 {
|
||||
if idx := strings.LastIndex(value, "-"); idx >= 0 && idx < len(value)-1 {
|
||||
value = value[idx+1:]
|
||||
|
|
@ -791,7 +1190,44 @@ func cloneConversationMap(src map[string]Conversation) map[string]Conversation {
|
|||
|
||||
func cloneConversation(conv Conversation) Conversation {
|
||||
clone := conv
|
||||
clone.Messages = append([]ChatMessage(nil), conv.Messages...)
|
||||
clone.Messages = cloneMessages(conv.Messages)
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneMessages(messages []ChatMessage) []ChatMessage {
|
||||
clones := make([]ChatMessage, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
clones = append(clones, cloneMessage(message))
|
||||
}
|
||||
return clones
|
||||
}
|
||||
|
||||
func cloneMessage(message ChatMessage) ChatMessage {
|
||||
clone := message
|
||||
clone.Attachments = append([]ImageAttachment(nil), message.Attachments...)
|
||||
if message.Thinking != nil {
|
||||
copyThinking := *message.Thinking
|
||||
clone.Thinking = ©Thinking
|
||||
}
|
||||
if len(message.ToolCalls) > 0 {
|
||||
clone.ToolCalls = make([]ToolInvocation, 0, len(message.ToolCalls))
|
||||
for _, invocation := range message.ToolCalls {
|
||||
clone.ToolCalls = append(clone.ToolCalls, ToolInvocation{
|
||||
Call: ToolCall{
|
||||
ID: invocation.Call.ID,
|
||||
Name: invocation.Call.Name,
|
||||
Arguments: cloneAnyMap(invocation.Call.Arguments),
|
||||
},
|
||||
Result: ToolResult{
|
||||
ToolCallID: invocation.Result.ToolCallID,
|
||||
Content: invocation.Result.Content,
|
||||
},
|
||||
StartedAt: invocation.StartedAt,
|
||||
EndedAt: invocation.EndedAt,
|
||||
Error: invocation.Error,
|
||||
})
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
|
|
@ -818,3 +1254,63 @@ func cloneStringMap(src map[string]string) map[string]string {
|
|||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func cloneAnyMap(src map[string]any) map[string]any {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make(map[string]any, len(src))
|
||||
for key, value := range src {
|
||||
dst[key] = value
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func latestStreamingAssistantIndex(messages []ChatMessage) int {
|
||||
for index := len(messages) - 1; index >= 0; index-- {
|
||||
if messages[index].Role == "assistant" && messages[index].Streaming {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (s *ChatStore) syncThinkingToStreamingMessageLocked(conversationID string, state ThinkingState) {
|
||||
conv, ok := s.conversations[conversationID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
index := latestStreamingAssistantIndex(conv.Messages)
|
||||
if index < 0 {
|
||||
return
|
||||
}
|
||||
copyThinking := state
|
||||
conv.Messages[index].Thinking = ©Thinking
|
||||
s.conversations[conversationID] = conv
|
||||
}
|
||||
|
||||
func (s *ChatStore) ensureAttachmentIdentifiersLocked() {
|
||||
for conversationID, attachments := range s.queuedImages {
|
||||
for index := range attachments {
|
||||
if attachments[index].ID == "" {
|
||||
attachments[index].ID = s.nextIdentifier("img")
|
||||
}
|
||||
}
|
||||
s.queuedImages[conversationID] = attachments
|
||||
}
|
||||
for conversationID, conv := range s.conversations {
|
||||
changed := false
|
||||
for messageIndex := range conv.Messages {
|
||||
for attachmentIndex := range conv.Messages[messageIndex].Attachments {
|
||||
if conv.Messages[messageIndex].Attachments[attachmentIndex].ID != "" {
|
||||
continue
|
||||
}
|
||||
conv.Messages[messageIndex].Attachments[attachmentIndex].ID = s.nextIdentifier("img")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.conversations[conversationID] = conv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,31 @@ func TestChatLifecycle_Good(t *testing.T) {
|
|||
conv := convResult.(Conversation)
|
||||
require.NotEmpty(t, conv.ID)
|
||||
|
||||
_, handled, err = c.PERFORM(TaskAttachImage{
|
||||
updatedAttachments, handled, err := c.PERFORM(TaskAttachImage{
|
||||
ConversationID: conv.ID,
|
||||
Attachment: ImageAttachment{
|
||||
Filename: "diagram.png",
|
||||
MimeType: "image/png",
|
||||
Data: "ZmFrZQ==",
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
attachments := updatedAttachments.([]ImageAttachment)
|
||||
require.Len(t, attachments, 1)
|
||||
require.NotEmpty(t, attachments[0].ID)
|
||||
|
||||
remainingResult, handled, err := c.PERFORM(TaskDetachImage{
|
||||
ConversationID: conv.ID,
|
||||
AttachmentID: attachments[0].ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
assert.Empty(t, remainingResult.([]ImageAttachment))
|
||||
|
||||
updatedAttachments, handled, err = c.PERFORM(TaskAttachImage{
|
||||
ConversationID: conv.ID,
|
||||
Attachment: ImageAttachment{
|
||||
Filename: "diagram.png",
|
||||
|
|
@ -81,6 +105,22 @@ func TestChatLifecycle_Good(t *testing.T) {
|
|||
require.True(t, handled)
|
||||
require.Len(t, searchResult.([]Conversation), 1)
|
||||
|
||||
renamedResult, handled, err := c.PERFORM(TaskConversationRename{
|
||||
ID: conv.ID,
|
||||
Title: "Local inference notes",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
assert.Equal(t, "Local inference notes", renamedResult.(Conversation).Title)
|
||||
|
||||
exportedResult, handled, err := c.QUERY(QueryConversationExport{ID: conv.ID})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
exported := exportedResult.(string)
|
||||
assert.Contains(t, exported, "# Local inference notes")
|
||||
assert.Contains(t, exported, "## User")
|
||||
assert.Contains(t, exported, "diagram.png")
|
||||
|
||||
settingsResult, handled, err := c.PERFORM(TaskChatSettingsSave{
|
||||
Settings: ChatSettings{
|
||||
Temperature: 0.7,
|
||||
|
|
@ -105,6 +145,82 @@ func TestChatLifecycle_Good(t *testing.T) {
|
|||
require.NoError(t, svc.chat.persist(svc.configFile))
|
||||
}
|
||||
|
||||
func TestChatStreamingLifecycle_Good(t *testing.T) {
|
||||
_, c := newTestDisplayService(t)
|
||||
|
||||
convResult, handled, err := c.PERFORM(TaskConversationNew{})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
conv := convResult.(Conversation)
|
||||
|
||||
_, handled, err = c.PERFORM(TaskChatSend{
|
||||
ConversationID: conv.ID,
|
||||
Content: "Stream the answer instead.",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
|
||||
startResult, handled, err := c.PERFORM(TaskChatStreamStart{ConversationID: conv.ID})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
started := startResult.(Conversation)
|
||||
require.Len(t, started.Messages, 2)
|
||||
assert.True(t, started.Messages[1].Streaming)
|
||||
assert.Empty(t, started.Messages[1].Content)
|
||||
|
||||
_, handled, err = c.PERFORM(TaskThinkingStart{ConversationID: conv.ID})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
|
||||
_, handled, err = c.PERFORM(TaskThinkingAppend{
|
||||
ConversationID: conv.ID,
|
||||
Content: "Streaming through the local bridge.",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
|
||||
appendResult, handled, err := c.PERFORM(TaskChatStreamAppend{
|
||||
ConversationID: conv.ID,
|
||||
Content: "Hello",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
appended := appendResult.(Conversation)
|
||||
require.Len(t, appended.Messages, 2)
|
||||
assert.Equal(t, "Hello", appended.Messages[1].Content)
|
||||
if assert.NotNil(t, appended.Messages[1].Thinking) {
|
||||
assert.Contains(t, appended.Messages[1].Thinking.Content, "local bridge")
|
||||
}
|
||||
|
||||
appendResult, handled, err = c.PERFORM(TaskChatStreamAppend{
|
||||
ConversationID: conv.ID,
|
||||
Content: " world",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
appended = appendResult.(Conversation)
|
||||
assert.Equal(t, "Hello world", appended.Messages[1].Content)
|
||||
|
||||
finishResult, handled, err := c.PERFORM(TaskChatStreamFinish{
|
||||
ConversationID: conv.ID,
|
||||
FinishReason: "stop",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
finished := finishResult.(Conversation)
|
||||
require.Len(t, finished.Messages, 2)
|
||||
assert.False(t, finished.Messages[1].Streaming)
|
||||
assert.Equal(t, "stop", finished.Messages[1].FinishReason)
|
||||
|
||||
historyResult, handled, err := c.QUERY(QueryChatHistory{ConversationID: conv.ID})
|
||||
require.NoError(t, err)
|
||||
require.True(t, handled)
|
||||
history := historyResult.([]ChatMessage)
|
||||
require.Len(t, history, 2)
|
||||
assert.Equal(t, "Hello world", history[1].Content)
|
||||
assert.False(t, history[1].Streaming)
|
||||
}
|
||||
|
||||
func TestChatPersistence_Good(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "gui.yaml")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue