diff --git a/pkg/display/chat.go b/pkg/display/chat.go index 60fb0e3e..b45910bd 100644 --- a/pkg/display/chat.go +++ b/pkg/display/chat.go @@ -561,7 +561,8 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C if !ok { return Conversation{}, ChatMessage{}, ChatMessage{}, coreerr.E("display.chat.SendMessage", "conversation not found: "+conversationID, nil) } - if strings.TrimSpace(content) == "" { + queuedAttachments := append([]ImageAttachment(nil), s.queuedImages[conversationID]...) + if strings.TrimSpace(content) == "" && len(queuedAttachments) == 0 { return Conversation{}, ChatMessage{}, ChatMessage{}, coreerr.E("display.chat.SendMessage", "message content is required", nil) } @@ -572,12 +573,12 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C Role: "user", Content: content, CreatedAt: now, - Attachments: append([]ImageAttachment(nil), s.queuedImages[conversationID]...), + Attachments: queuedAttachments, } assistantMessage := ChatMessage{ ID: s.nextIdentifier("msg"), Role: "assistant", - Content: buildAssistantPlaceholder(model, content), + Content: buildAssistantPlaceholder(model, content, len(queuedAttachments)), CreatedAt: now.Add(250 * time.Millisecond), Streaming: false, } @@ -591,7 +592,7 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C conv.Messages = append(conv.Messages, userMessage, assistantMessage) if len(conv.Messages) == 2 { - conv.Title = deriveConversationTitle(content) + conv.Title = deriveConversationTitleForMessage(content, queuedAttachments) } conv.UpdatedAt = assistantMessage.CreatedAt conv.Model = model @@ -879,6 +880,8 @@ type QueryChatHistory struct { ConversationID string `json:"conversation_id"` } +type QueryChatSnapshot struct{} + type TaskChatSend struct { ConversationID string `json:"conversation_id"` Content string `json:"content"` @@ -984,6 +987,8 @@ func (s *Service) handleChatQuery(_ *core.Core, q core.Query) (any, bool, error) case QueryChatHistory: history, err := s.chat.History(q.ConversationID) return history, true, err + case QueryChatSnapshot: + return s.chat.Snapshot(), true, nil case QueryChatModels: return s.chat.Models(), true, nil case QueryChatSettingsLoad: @@ -1289,6 +1294,19 @@ func deriveConversationTitle(content string) string { return strings.TrimSpace(string(runes[:50])) + "..." } +func deriveConversationTitleForMessage(content string, attachments []ImageAttachment) string { + if title := deriveConversationTitle(strings.TrimSpace(content)); title != "New conversation" { + return title + } + if len(attachments) == 0 { + return "New conversation" + } + if name := strings.TrimSpace(attachments[0].Filename); name != "" { + return deriveConversationTitle("Image: " + name) + } + return "Image conversation" +} + func conversationMatchesSearchQuery(conv Conversation, query string) bool { if query == "" { return true @@ -1323,14 +1341,18 @@ func messageSearchHaystack(message ChatMessage) string { return strings.Join(parts, " ") } -func buildAssistantPlaceholder(model, prompt string) string { +func buildAssistantPlaceholder(model, prompt string, attachmentCount int) string { prompt = strings.TrimSpace(prompt) - if prompt == "" { - return "Waiting for the local inference pipeline." - } if model == "" { model = "local model" } + if prompt == "" { + if attachmentCount > 0 { + return "Local inference is not wired in this workspace yet. " + + "Captured " + strconvFormatUint(uint64(attachmentCount)) + " image attachment(s) for " + model + " and stored them in chat history." + } + return "Waiting for the local inference pipeline." + } return "Local inference is not wired in this workspace yet. " + "Captured your prompt for " + model + " and stored it in chat history." } @@ -1361,7 +1383,7 @@ func canReuseAssistantForStreaming(conv Conversation) bool { if previous.Role != "user" { return false } - return last.Content == buildAssistantPlaceholder(conv.Model, previous.Content) + return last.Content == buildAssistantPlaceholder(conv.Model, previous.Content, len(previous.Attachments)) } func parseCounter(value string) uint64 { @@ -1584,8 +1606,14 @@ func (s *ChatStore) normalizeMessagesLocked(messages []ChatMessage, base time.Ti func firstUserMessage(messages []ChatMessage) string { for _, message := range messages { - if message.Role == "user" && strings.TrimSpace(message.Content) != "" { - return message.Content + if message.Role != "user" { + continue + } + if content := strings.TrimSpace(message.Content); content != "" { + return content + } + if len(message.Attachments) > 0 { + return deriveConversationTitleForMessage("", message.Attachments) } } return "" diff --git a/pkg/display/chat_test.go b/pkg/display/chat_test.go index ecb89fc1..3d676ffc 100644 --- a/pkg/display/chat_test.go +++ b/pkg/display/chat_test.go @@ -221,6 +221,43 @@ func TestChatStreamingLifecycle_Good(t *testing.T) { assert.False(t, history[1].Streaming) } +func TestChatSendAllowsAttachmentOnlyMessages_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(TaskAttachImage{ + ConversationID: conv.ID, + Attachment: ImageAttachment{ + Filename: "reference.png", + MimeType: "image/png", + Data: "ZmFrZQ==", + Width: 512, + Height: 512, + }, + }) + require.NoError(t, err) + require.True(t, handled) + + sendResult, handled, err := c.PERFORM(TaskChatSend{ + ConversationID: conv.ID, + Content: "", + }) + require.NoError(t, err) + require.True(t, handled) + + updated := sendResult.(Conversation) + require.Len(t, updated.Messages, 2) + assert.Empty(t, updated.Messages[0].Content) + require.Len(t, updated.Messages[0].Attachments, 1) + assert.Equal(t, "reference.png", updated.Messages[0].Attachments[0].Filename) + assert.Contains(t, updated.Messages[1].Content, "image attachment") + assert.Equal(t, "Image: reference.png", updated.Title) +} + func TestChatPersistence_Good(t *testing.T) { path := filepath.Join(t.TempDir(), "gui.yaml") @@ -580,3 +617,33 @@ func TestRouteQueriesAndStoreGroups_Good(t *testing.T) { } assert.Contains(t, snippets, "invoice status is paid") } + +func TestChatSnapshotQuery_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(TaskAttachImage{ + ConversationID: conv.ID, + Attachment: ImageAttachment{ + Filename: "state.png", + MimeType: "image/png", + Data: "ZmFrZQ==", + }, + }) + require.NoError(t, err) + require.True(t, handled) + + snapshotResult, handled, err := c.QUERY(QueryChatSnapshot{}) + require.NoError(t, err) + require.True(t, handled) + + snapshot := snapshotResult.(ChatSnapshot) + assert.Equal(t, "lemer", snapshot.SelectedModel) + require.Contains(t, snapshot.Conversations, conv.ID) + require.Contains(t, snapshot.QueuedImages, conv.ID) + require.Len(t, snapshot.QueuedImages[conv.ID], 1) +} diff --git a/pkg/display/display.go b/pkg/display/display.go index ad7ed587..f38a652f 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -288,6 +288,17 @@ func wsRequire(data map[string]any, key string) (string, error) { return v, nil } +func decodeWSData(data map[string]any, target any) error { + encodedR := corego.JSONMarshal(data) + if !encodedR.OK { + return corego.NewError("ws: invalid payload") + } + if r := corego.JSONUnmarshal(encodedR.Value.([]byte), target); !r.OK { + return corego.E("display.ws", "invalid payload", nil) + } + return nil +} + // handleWSMessage bridges WebSocket commands to IPC calls. func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { var result any @@ -336,6 +347,129 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { return nil, false, createErr } result, handled, err = info, true, nil + case "chat:snapshot": + result, handled, err = s.Core().QUERY(QueryChatSnapshot{}) + case "chat:models": + result, handled, err = s.Core().QUERY(QueryChatModels{}) + case "chat:model-select": + model, e := wsRequire(msg.Data, "model") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(TaskSelectModel{Model: model}) + case "chat:settings-load": + result, handled, err = s.Core().QUERY(QueryChatSettingsLoad{}) + case "chat:settings-save": + var settings ChatSettings + if err := decodeWSData(msg.Data, &settings); err != nil { + return nil, false, err + } + result, handled, err = s.Core().PERFORM(TaskChatSettingsSave{Settings: settings}) + case "chat:settings-reset": + result, handled, err = s.Core().PERFORM(TaskChatSettingsReset{}) + case "chat:conversations": + result, handled, err = s.Core().QUERY(QueryConversationsList{}) + case "chat:conversation-get": + id, e := wsRequire(msg.Data, "id") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().QUERY(QueryConversationGet{ID: id}) + case "chat:conversation-search": + query, _ := msg.Data["q"].(string) + result, handled, err = s.Core().QUERY(QueryConversationsSearch{Query: query}) + case "chat:conversation-new": + result, handled, err = s.Core().PERFORM(TaskConversationNew{}) + case "chat:conversation-rename": + var input TaskConversationRename + if err := decodeWSData(msg.Data, &input); err != nil { + return nil, false, err + } + result, handled, err = s.Core().PERFORM(input) + case "chat:conversation-delete": + id, e := wsRequire(msg.Data, "id") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(TaskConversationDelete{ID: id}) + case "chat:conversation-export": + id, e := wsRequire(msg.Data, "id") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().QUERY(QueryConversationExport{ID: id}) + case "chat:queued-images": + conversationID, e := wsRequire(msg.Data, "conversation_id") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().QUERY(QueryQueuedImages{ConversationID: conversationID}) + case "chat:attach-image": + var input TaskAttachImage + if err := decodeWSData(msg.Data, &input); err != nil { + return nil, false, err + } + result, handled, err = s.Core().PERFORM(input) + case "chat:detach-image": + var input TaskDetachImage + if err := decodeWSData(msg.Data, &input); err != nil { + return nil, false, err + } + result, handled, err = s.Core().PERFORM(input) + case "chat:send": + var input TaskChatSend + if err := decodeWSData(msg.Data, &input); err != nil { + return nil, false, err + } + result, handled, err = s.Core().PERFORM(input) + case "chat:clear": + conversationID, e := wsRequire(msg.Data, "conversation_id") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(TaskChatClear{ConversationID: conversationID}) + case "chat:thinking-start": + conversationID, e := wsRequire(msg.Data, "conversation_id") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(TaskThinkingStart{ConversationID: conversationID}) + case "chat:thinking-append": + var input TaskThinkingAppend + if err := decodeWSData(msg.Data, &input); err != nil { + return nil, false, err + } + result, handled, err = s.Core().PERFORM(input) + case "chat:thinking-end": + conversationID, e := wsRequire(msg.Data, "conversation_id") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(TaskThinkingEnd{ConversationID: conversationID}) + case "chat:tool-call": + var input TaskRecordToolCall + if err := decodeWSData(msg.Data, &input); err != nil { + return nil, false, err + } + result, handled, err = s.Core().PERFORM(input) + case "chat:stream-start": + conversationID, e := wsRequire(msg.Data, "conversation_id") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(TaskChatStreamStart{ConversationID: conversationID}) + case "chat:stream-append": + var input TaskChatStreamAppend + if err := decodeWSData(msg.Data, &input); err != nil { + return nil, false, err + } + result, handled, err = s.Core().PERFORM(input) + case "chat:stream-finish": + var input TaskChatStreamFinish + if err := decodeWSData(msg.Data, &input); err != nil { + return nil, false, err + } + result, handled, err = s.Core().PERFORM(input) case "route:resolve": rawURL, e := wsRequire(msg.Data, "url") if e != nil { diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index b267ff25..be0a6dc7 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -1270,6 +1270,79 @@ func TestHandleWSMessage_Extended_Good(t *testing.T) { assert.Equal(t, "OK", result) }) + t.Run("chat actions", func(t *testing.T) { + result, handled, err := svc.handleWSMessage(WSMessage{Action: "chat:snapshot"}) + require.NoError(t, err) + assert.True(t, handled) + _, ok := result.(ChatSnapshot) + require.True(t, ok) + + result, handled, err = svc.handleWSMessage(WSMessage{Action: "chat:conversation-new"}) + require.NoError(t, err) + assert.True(t, handled) + conversation, ok := result.(Conversation) + require.True(t, ok) + require.NotEmpty(t, conversation.ID) + + result, handled, err = svc.handleWSMessage(WSMessage{ + Action: "chat:settings-save", + Data: map[string]any{ + "temperature": 0.6, + "top_p": 0.9, + "top_k": float64(32), + "max_tokens": float64(1024), + "context_window": float64(4096), + "system_prompt": "Stay concise.", + "default_model": "lemma", + }, + }) + require.NoError(t, err) + assert.True(t, handled) + settings, ok := result.(ChatSettings) + require.True(t, ok) + assert.Equal(t, float32(0.6), settings.Temperature) + + _, handled, err = svc.handleWSMessage(WSMessage{ + Action: "chat:attach-image", + Data: map[string]any{ + "conversation_id": conversation.ID, + "attachment": map[string]any{ + "filename": "ws-chat.png", + "mime_type": "image/png", + "data": "ZmFrZQ==", + "width": float64(320), + "height": float64(180), + }, + }, + }) + require.NoError(t, err) + assert.True(t, handled) + + result, handled, err = svc.handleWSMessage(WSMessage{ + Action: "chat:send", + Data: map[string]any{ + "conversation_id": conversation.ID, + "content": "", + }, + }) + require.NoError(t, err) + assert.True(t, handled) + updatedConversation, ok := result.(Conversation) + require.True(t, ok) + require.Len(t, updatedConversation.Messages, 2) + assert.Len(t, updatedConversation.Messages[0].Attachments, 1) + + result, handled, err = svc.handleWSMessage(WSMessage{ + Action: "chat:conversation-export", + Data: map[string]any{"id": conversation.ID}, + }) + require.NoError(t, err) + assert.True(t, handled) + exported, ok := result.(string) + require.True(t, ok) + assert.Contains(t, exported, "ws-chat.png") + }) + t.Run("event info", func(t *testing.T) { result, handled, err := svc.handleWSMessage(WSMessage{Action: "event:info"}) require.NoError(t, err) diff --git a/ui/src/app/dashboard.component.ts b/ui/src/app/dashboard.component.ts index 0bcbe6ae..11d73db5 100644 --- a/ui/src/app/dashboard.component.ts +++ b/ui/src/app/dashboard.component.ts @@ -203,7 +203,10 @@ interface ConversationGroup {
{{ message.role === 'user' ? 'You' : 'Assistant' }} -
@@ -226,7 +229,11 @@ interface ConversationGroup { @if (message.thinking?.content) {
- + }