diff --git a/pkg/display/chat.go b/pkg/display/chat.go index d6b4851e..cc2a3cb6 100644 --- a/pkg/display/chat.go +++ b/pkg/display/chat.go @@ -274,6 +274,12 @@ func (s *ChatStore) Models() []ModelEntry { return append([]ModelEntry(nil), s.models...) } +func (s *ChatStore) SelectedModel() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.selectedModel +} + func (s *ChatStore) SelectModel(name string) ([]ModelEntry, error) { s.mu.Lock() defer s.mu.Unlock() @@ -323,6 +329,54 @@ func (s *ChatStore) ResetSettings() ChatSettings { return s.SaveSettings(defaultChatSettings()) } +func (s *ChatStore) SaveConversation(input Conversation) (Conversation, error) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UTC() + conv := cloneConversation(input) + existing, exists := s.conversations[conv.ID] + + if conv.ID == "" { + conv.ID = s.nextIdentifier("conv") + } + if conv.CreatedAt.IsZero() { + switch { + case exists && !existing.CreatedAt.IsZero(): + conv.CreatedAt = existing.CreatedAt + default: + conv.CreatedAt = now + } + } + if conv.Model == "" { + conv.Model = s.nextResponseModelLocked(conv) + } + if conv.Settings == nil && exists && existing.Settings != nil { + copySettings := *existing.Settings + conv.Settings = ©Settings + } + + conv.Messages = s.normalizeMessagesLocked(conv.Messages, conv.CreatedAt) + if strings.TrimSpace(conv.Title) == "" { + conv.Title = deriveConversationTitle(firstUserMessage(conv.Messages)) + } + if strings.TrimSpace(conv.Title) == "" { + conv.Title = "New conversation" + } + if conv.UpdatedAt.IsZero() { + conv.UpdatedAt = conv.CreatedAt + if len(conv.Messages) > 0 { + conv.UpdatedAt = conv.Messages[len(conv.Messages)-1].CreatedAt + } + } + if conv.UpdatedAt.Before(conv.CreatedAt) { + conv.UpdatedAt = conv.CreatedAt + } + + s.conversations[conv.ID] = conv + return cloneConversation(conv), nil +} + func (s *ChatStore) NewConversation() Conversation { s.mu.Lock() defer s.mu.Unlock() @@ -370,6 +424,16 @@ func (s *ChatStore) Conversation(id string) (Conversation, bool) { return cloneConversation(conv), true } +func (s *ChatStore) QueuedImages(conversationID string) ([]ImageAttachment, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if _, ok := s.conversations[conversationID]; !ok { + return nil, coreerr.E("display.chat.QueuedImages", "conversation not found: "+conversationID, nil) + } + return append([]ImageAttachment(nil), s.queuedImages[conversationID]...), nil +} + func (s *ChatStore) DeleteConversation(id string) bool { s.mu.Lock() defer s.mu.Unlock() @@ -446,9 +510,17 @@ func (s *ChatStore) QueueImage(conversationID string, attachment ImageAttachment s.mu.Lock() defer s.mu.Unlock() - if _, ok := s.conversations[conversationID]; !ok { + conv, ok := s.conversations[conversationID] + if !ok { return nil, coreerr.E("display.chat.QueueImage", "conversation not found: "+conversationID, nil) } + if err := validateImageAttachment(attachment); err != nil { + return nil, coreerr.E("display.chat.QueueImage", err.Error(), nil) + } + model := s.nextResponseModelLocked(conv) + if model != "" && !s.modelSupportsVisionLocked(model) { + return nil, coreerr.E("display.chat.QueueImage", "selected model does not support vision: "+model, nil) + } if attachment.ID == "" { attachment.ID = s.nextIdentifier("img") } @@ -501,6 +573,7 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C } now := time.Now().UTC() + model := s.nextResponseModelLocked(conv) userMessage := ChatMessage{ ID: s.nextIdentifier("msg"), Role: "user", @@ -511,7 +584,7 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C assistantMessage := ChatMessage{ ID: s.nextIdentifier("msg"), Role: "assistant", - Content: buildAssistantPlaceholder(conv.Model, content), + Content: buildAssistantPlaceholder(model, content), CreatedAt: now.Add(250 * time.Millisecond), Streaming: false, } @@ -528,9 +601,7 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C conv.Title = deriveConversationTitle(content) } conv.UpdatedAt = assistantMessage.CreatedAt - if conv.Model == "" { - conv.Model = s.selectedModel - } + conv.Model = model s.conversations[conversationID] = conv delete(s.queuedImages, conversationID) delete(s.streamingMessage, conversationID) @@ -548,6 +619,7 @@ func (s *ChatStore) StartStreaming(conversationID string) (Conversation, ChatMes } now := time.Now().UTC() + conv.Model = s.nextResponseModelLocked(conv) var assistantMessage *ChatMessage if canReuseAssistantForStreaming(conv) { assistantMessage = &conv.Messages[len(conv.Messages)-1] @@ -658,6 +730,9 @@ func (s *ChatStore) AppendThinking(conversationID, content string) (ThinkingStat s.mu.Lock() defer s.mu.Unlock() + if _, ok := s.conversations[conversationID]; !ok { + return ThinkingState{}, coreerr.E("display.chat.AppendThinking", "conversation not found: "+conversationID, nil) + } state, ok := s.thinking[conversationID] if !ok { state = ThinkingState{Active: true, StartedAt: time.Now().UTC()} @@ -692,14 +767,16 @@ func (s *ChatStore) RecordToolResult(conversationID string, call ToolCall, resul if !ok { return Conversation{}, coreerr.E("display.chat.RecordToolResult", "conversation not found: "+conversationID, nil) } - if len(conv.Messages) == 0 { - return Conversation{}, coreerr.E("display.chat.RecordToolResult", "conversation has no messages", nil) + index := latestAssistantIndex(conv.Messages) + if index < 0 { + return Conversation{}, coreerr.E("display.chat.RecordToolResult", "assistant message not found", nil) } - last := &conv.Messages[len(conv.Messages)-1] + last := &conv.Messages[index] + startedAt := time.Now().UTC() last.ToolCalls = append(last.ToolCalls, ToolInvocation{ Call: call, Result: result, - StartedAt: time.Now().UTC(), + StartedAt: startedAt, EndedAt: time.Now().UTC(), Error: errText, }) @@ -842,6 +919,10 @@ type QueryConversationsSearch struct { Query string `json:"q"` } +type QueryQueuedImages struct { + ConversationID string `json:"conversation_id"` +} + type QueryConversationExport struct { ID string `json:"id"` } @@ -852,6 +933,10 @@ type TaskConversationDelete struct { type TaskConversationNew struct{} +type TaskConversationSave struct { + Conversation Conversation `json:"conversation"` +} + type TaskConversationRename struct { ID string `json:"id"` Title string `json:"title"` @@ -920,6 +1005,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 QueryQueuedImages: + attachments, err := s.chat.QueuedImages(q.ConversationID) + return attachments, true, err case QueryConversationExport: content, err := s.chat.ExportConversationMarkdown(q.ID) return content, true, err @@ -932,6 +1020,28 @@ func (s *Service) handleChatTask(_ *core.Core, t core.Task) (any, bool, error) { switch t := t.(type) { case TaskConversationNew: conv := s.chat.NewConversation() + if s.events != nil { + s.events.Emit(Event{ + Type: "chat.conversation.created", + Data: map[string]any{ + "conversation": conv, + }, + }) + } + return conv, true, s.chat.persist(s.configFile) + case TaskConversationSave: + conv, err := s.chat.SaveConversation(t.Conversation) + if err != nil { + return nil, true, err + } + if s.events != nil { + s.events.Emit(Event{ + Type: "chat.conversation.saved", + Data: map[string]any{ + "conversation": conv, + }, + }) + } return conv, true, s.chat.persist(s.configFile) case TaskConversationRename: conv, err := s.chat.RenameConversation(t.ID, t.Title) @@ -969,24 +1079,65 @@ 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.cleared", + Data: map[string]any{ + "conversation_id": conv.ID, + }, + }) + } return conv, true, s.chat.persist(s.configFile) case TaskSelectModel: models, err := s.chat.SelectModel(t.Model) if err != nil { return nil, true, err } + if s.events != nil { + s.events.Emit(Event{ + Type: "chat.model.selected", + Data: map[string]any{ + "model": t.Model, + "models": models, + }, + }) + } return models, true, s.chat.persist(s.configFile) case TaskChatSettingsSave: settings := s.chat.SaveSettings(t.Settings) + if s.events != nil { + s.events.Emit(Event{ + Type: "chat.settings.updated", + Data: map[string]any{ + "settings": settings, + }, + }) + } return settings, true, s.chat.persist(s.configFile) case TaskChatSettingsReset: settings := s.chat.ResetSettings() + if s.events != nil { + s.events.Emit(Event{ + Type: "chat.settings.updated", + Data: map[string]any{ + "settings": settings, + }, + }) + } return settings, true, s.chat.persist(s.configFile) case TaskConversationDelete: deleted := s.chat.DeleteConversation(t.ID) if !deleted { return nil, true, coreerr.E("display.chat.TaskConversationDelete", "conversation not found: "+t.ID, nil) } + if s.events != nil { + s.events.Emit(Event{ + Type: "chat.conversation.deleted", + Data: map[string]any{ + "conversation_id": t.ID, + }, + }) + } return deleted, true, s.chat.persist(s.configFile) case TaskAttachImage: attachments, err := s.chat.QueueImage(t.ConversationID, t.Attachment) @@ -1023,24 +1174,63 @@ 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.thinking.start", + Data: map[string]any{ + "conversation_id": t.ConversationID, + "thinking": state, + }, + }) + } return state, true, s.chat.persist(s.configFile) case TaskThinkingAppend: state, err := s.chat.AppendThinking(t.ConversationID, t.Content) if err != nil { return nil, true, err } + if s.events != nil { + s.events.Emit(Event{ + Type: "chat.thinking.delta", + Data: map[string]any{ + "conversation_id": t.ConversationID, + "thinking": state, + "delta": t.Content, + }, + }) + } return state, true, s.chat.persist(s.configFile) case TaskThinkingEnd: state, err := s.chat.EndThinking(t.ConversationID) if err != nil { return nil, true, err } + if s.events != nil { + s.events.Emit(Event{ + Type: "chat.thinking.end", + Data: map[string]any{ + "conversation_id": t.ConversationID, + "thinking": state, + }, + }) + } return state, true, s.chat.persist(s.configFile) case TaskRecordToolCall: conv, err := s.chat.RecordToolResult(t.ConversationID, t.Call, t.Result, t.Error) if err != nil { return nil, true, err } + if s.events != nil { + s.events.Emit(Event{ + Type: "chat.tool.call", + Data: map[string]any{ + "conversation_id": t.ConversationID, + "call": t.Call, + "result": t.Result, + "error": t.Error, + }, + }) + } return conv, true, s.chat.persist(s.configFile) case TaskChatStreamStart: conv, message, err := s.chat.StartStreaming(t.ConversationID) @@ -1275,6 +1465,15 @@ func latestStreamingAssistantIndex(messages []ChatMessage) int { return -1 } +func latestAssistantIndex(messages []ChatMessage) int { + for index := len(messages) - 1; index >= 0; index-- { + if messages[index].Role == "assistant" { + return index + } + } + return -1 +} + func (s *ChatStore) syncThinkingToStreamingMessageLocked(conversationID string, state ThinkingState) { conv, ok := s.conversations[conversationID] if !ok { @@ -1314,3 +1513,68 @@ func (s *ChatStore) ensureAttachmentIdentifiersLocked() { } } } + +func (s *ChatStore) nextResponseModelLocked(conv Conversation) string { + if s.selectedModel != "" { + return s.selectedModel + } + if conv.Model != "" { + return conv.Model + } + return s.settings.DefaultModel +} + +func (s *ChatStore) modelSupportsVisionLocked(name string) bool { + for _, model := range s.models { + if model.Name == name { + return model.SupportsVision + } + } + return false +} + +func (s *ChatStore) normalizeMessagesLocked(messages []ChatMessage, base time.Time) []ChatMessage { + if base.IsZero() { + base = time.Now().UTC() + } + normalized := make([]ChatMessage, 0, len(messages)) + for index, message := range messages { + if message.ID == "" { + message.ID = s.nextIdentifier("msg") + } + if message.CreatedAt.IsZero() { + message.CreatedAt = base.Add(time.Duration(index) * time.Millisecond) + } + for attachmentIndex := range message.Attachments { + if message.Attachments[attachmentIndex].ID == "" { + message.Attachments[attachmentIndex].ID = s.nextIdentifier("img") + } + } + normalized = append(normalized, message) + } + return normalized +} + +func firstUserMessage(messages []ChatMessage) string { + for _, message := range messages { + if message.Role == "user" && strings.TrimSpace(message.Content) != "" { + return message.Content + } + } + return "" +} + +func validateImageAttachment(attachment ImageAttachment) error { + if strings.TrimSpace(attachment.Filename) == "" { + return coreerr.E("display.chat.validateImageAttachment", "attachment filename is required", nil) + } + if strings.TrimSpace(attachment.Data) == "" { + return coreerr.E("display.chat.validateImageAttachment", "attachment data is required", nil) + } + switch strings.TrimSpace(strings.ToLower(attachment.MimeType)) { + case "image/png", "image/jpeg", "image/webp", "image/gif": + return nil + default: + return coreerr.E("display.chat.validateImageAttachment", "unsupported image type: "+attachment.MimeType, nil) + } +} diff --git a/pkg/display/chat_test.go b/pkg/display/chat_test.go index 26917fe4..a297053d 100644 --- a/pkg/display/chat_test.go +++ b/pkg/display/chat_test.go @@ -243,6 +243,174 @@ func TestChatPersistence_Good(t *testing.T) { assert.Equal(t, "Persist this conversation.", restored.Messages[0].Content) } +func TestChatConversationSave_Good(t *testing.T) { + _, c := newTestDisplayService(t) + + result, handled, err := c.PERFORM(TaskConversationSave{ + Conversation: Conversation{ + Messages: []ChatMessage{ + {Role: "user", Content: "Imported conversation content that should title itself."}, + {Role: "assistant", Content: "Imported response."}, + }, + }, + }) + require.NoError(t, err) + require.True(t, handled) + + conv := result.(Conversation) + require.NotEmpty(t, conv.ID) + require.Len(t, conv.Messages, 2) + assert.Contains(t, conv.Title, "Imported conversation content") + assert.True(t, len(conv.Title) >= len("Imported conversation content")) + assert.NotEmpty(t, conv.Messages[0].ID) + assert.NotEmpty(t, conv.Messages[1].ID) + assert.False(t, conv.CreatedAt.IsZero()) + assert.False(t, conv.UpdatedAt.IsZero()) + + loadedResult, handled, err := c.QUERY(QueryConversationGet{ID: conv.ID}) + require.NoError(t, err) + require.True(t, handled) + loaded := loadedResult.(Conversation) + assert.Equal(t, conv.ID, loaded.ID) + assert.Equal(t, conv.Title, loaded.Title) +} + +func TestChatSelectedModelAppliesMidConversation_Good(t *testing.T) { + _, c := newTestDisplayService(t) + + convResult, handled, err := c.PERFORM(TaskConversationNew{}) + require.NoError(t, err) + require.True(t, handled) + conv := convResult.(Conversation) + assert.Equal(t, "lemer", conv.Model) + + _, handled, err = c.PERFORM(TaskChatSend{ + ConversationID: conv.ID, + Content: "Use the default model first.", + }) + require.NoError(t, err) + require.True(t, handled) + + _, handled, err = c.PERFORM(TaskSelectModel{Model: "lemma"}) + require.NoError(t, err) + require.True(t, handled) + + result, handled, err := c.PERFORM(TaskChatSend{ + ConversationID: conv.ID, + Content: "Now switch to the new model.", + }) + require.NoError(t, err) + require.True(t, handled) + + updated := result.(Conversation) + require.Len(t, updated.Messages, 4) + assert.Equal(t, "lemma", updated.Model) + assert.Contains(t, updated.Messages[3].Content, "lemma") +} + +func TestChatAttachmentValidation_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(TaskSelectModel{Model: "lemmy"}) + require.NoError(t, err) + require.True(t, handled) + + _, handled, err = c.PERFORM(TaskAttachImage{ + ConversationID: conv.ID, + Attachment: ImageAttachment{ + Filename: "photo.png", + MimeType: "image/png", + Data: "ZmFrZQ==", + }, + }) + require.Error(t, err) + assert.True(t, handled) + assert.Contains(t, err.Error(), "does not support vision") + + _, handled, err = c.PERFORM(TaskSelectModel{Model: "lemer"}) + require.NoError(t, err) + require.True(t, handled) + + _, handled, err = c.PERFORM(TaskAttachImage{ + ConversationID: conv.ID, + Attachment: ImageAttachment{ + Filename: "photo.svg", + MimeType: "image/svg+xml", + Data: "ZmFrZQ==", + }, + }) + require.Error(t, err) + assert.True(t, handled) + assert.Contains(t, err.Error(), "unsupported image type") +} + +func TestChatQueuedImagesQuery_Good(t *testing.T) { + _, c := newTestDisplayService(t) + + convResult, handled, err := c.PERFORM(TaskConversationNew{}) + require.NoError(t, err) + require.True(t, handled) + conv := convResult.(Conversation) + + updatedAttachments, handled, err := c.PERFORM(TaskAttachImage{ + ConversationID: conv.ID, + Attachment: ImageAttachment{ + Filename: "diagram.webp", + MimeType: "image/webp", + Data: "ZmFrZQ==", + }, + }) + require.NoError(t, err) + require.True(t, handled) + require.Len(t, updatedAttachments.([]ImageAttachment), 1) + + result, handled, err := c.QUERY(QueryQueuedImages{ConversationID: conv.ID}) + require.NoError(t, err) + require.True(t, handled) + attachments := result.([]ImageAttachment) + require.Len(t, attachments, 1) + assert.Equal(t, "diagram.webp", attachments[0].Filename) +} + +func TestChatTaskEvents_Good(t *testing.T) { + svc, c := newTestDisplayService(t) + svc.events = &WSEventManager{eventBuffer: make(chan Event, 8)} + + convResult, handled, err := c.PERFORM(TaskConversationNew{}) + require.NoError(t, err) + require.True(t, handled) + createdEvent := <-svc.events.eventBuffer + assert.Equal(t, EventType("chat.conversation.created"), createdEvent.Type) + + conv := convResult.(Conversation) + _, handled, err = c.PERFORM(TaskThinkingStart{ConversationID: conv.ID}) + require.NoError(t, err) + require.True(t, handled) + startEvent := <-svc.events.eventBuffer + assert.Equal(t, EventType("chat.thinking.start"), startEvent.Type) + + _, handled, err = c.PERFORM(TaskThinkingAppend{ + ConversationID: conv.ID, + Content: "Inspecting the prompt.", + }) + require.NoError(t, err) + require.True(t, handled) + appendEvent := <-svc.events.eventBuffer + assert.Equal(t, EventType("chat.thinking.delta"), appendEvent.Type) + assert.Equal(t, "Inspecting the prompt.", appendEvent.Data["delta"]) + + _, handled, err = c.PERFORM(TaskThinkingEnd{ConversationID: conv.ID}) + require.NoError(t, err) + require.True(t, handled) + endEvent := <-svc.events.eventBuffer + assert.Equal(t, EventType("chat.thinking.end"), endEvent.Type) +} + func TestResolveScheme_Good(t *testing.T) { c, err := core.New( core.WithService(Register(nil)), @@ -267,4 +435,10 @@ func TestResolveScheme_Good(t *testing.T) { assert.Equal(t, "settings", settingsResponse.Path) assert.Contains(t, settingsResponse.Data, "settings") assert.Contains(t, settingsResponse.Data, "models") + + modelsResponse, err := svc.ResolveScheme(context.Background(), "core://models") + require.NoError(t, err) + assert.Equal(t, "models", modelsResponse.Path) + assert.Contains(t, modelsResponse.Data, "selected_model") + assert.Contains(t, modelsResponse.Data, "models") } diff --git a/pkg/display/routes.go b/pkg/display/routes.go index d7d58d1e..cee4c814 100644 --- a/pkg/display/routes.go +++ b/pkg/display/routes.go @@ -78,6 +78,18 @@ func (s *Service) handleCoreScheme(_ context.Context, path string, params url.Va "models": models, }, }, nil + case "models": + models := s.chat.Models() + return SchemeResponse{ + Scheme: "core", + Path: "models", + ContentType: "application/json", + StatusCode: 200, + Data: map[string]any{ + "selected_model": s.chat.SelectedModel(), + "models": models, + }, + }, nil case "store": query := strings.TrimSpace(params.Get("q")) results := s.searchStore(query) diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index 388b1709..95795838 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -6,8 +6,11 @@ import ( "testing" "dappco.re/go/core/gui/pkg/clipboard" + "dappco.re/go/core/gui/pkg/display" "dappco.re/go/core/gui/pkg/environment" + "dappco.re/go/core/gui/pkg/notification" "dappco.re/go/core/gui/pkg/screen" + "dappco.re/go/core/gui/pkg/webview" "dappco.re/go/core/gui/pkg/window" "forge.lthn.ai/core/go/pkg/core" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -133,6 +136,89 @@ func TestMCP_Good_WindowTitleSetAlias(t *testing.T) { assert.Equal(t, "Updated", info.Title) } +func TestMCP_Good_ChatRoundTrip(t *testing.T) { + c, err := core.New( + core.WithService(display.Register(nil)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + sub := New(c) + + _, created, err := sub.chatConversationNew(context.Background(), nil, ChatConversationNewInput{}) + require.NoError(t, err) + require.NotEmpty(t, created.Conversation.ID) + + _, models, err := sub.chatModels(context.Background(), nil, ChatModelsInput{}) + require.NoError(t, err) + assert.Equal(t, "lemer", models.SelectedModel) + + _, selected, err := sub.chatSelectModel(context.Background(), nil, ChatSelectModelInput{Model: "lemma"}) + require.NoError(t, err) + assert.Equal(t, "lemma", selected.SelectedModel) + + _, sent, err := sub.chatSend(context.Background(), nil, ChatSendInput{ + ConversationID: created.Conversation.ID, + Content: "Summarise the RFC delta.", + }) + require.NoError(t, err) + require.Len(t, sent.Conversation.Messages, 2) + assert.Equal(t, "lemma", sent.Conversation.Model) + + _, history, err := sub.chatHistory(context.Background(), nil, ChatHistoryInput{ + ConversationID: created.Conversation.ID, + }) + require.NoError(t, err) + require.Len(t, history.Messages, 2) + + _, exported, err := sub.chatConversationExport(context.Background(), nil, ChatConversationExportInput{ + ID: created.Conversation.ID, + }) + require.NoError(t, err) + assert.Contains(t, exported.Markdown, "Summarise the RFC delta.") +} + +func TestMCP_Good_ChatConversationSaveAndAttachments(t *testing.T) { + c, err := core.New( + core.WithService(display.Register(nil)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + sub := New(c) + + _, saved, err := sub.chatConversationSave(context.Background(), nil, ChatConversationSaveInput{ + Conversation: display.Conversation{ + Messages: []display.ChatMessage{ + {Role: "user", Content: "Imported via MCP."}, + }, + }, + }) + require.NoError(t, err) + require.NotEmpty(t, saved.Conversation.ID) + assert.Equal(t, "Imported via MCP.", saved.Conversation.Title) + + _, attachments, err := sub.chatAttachImage(context.Background(), nil, ChatAttachImageInput{ + ConversationID: saved.Conversation.ID, + Attachment: display.ImageAttachment{ + Filename: "diagram.png", + MimeType: "image/png", + Data: "ZmFrZQ==", + }, + }) + require.NoError(t, err) + require.Len(t, attachments.Attachments, 1) + + _, listed, err := sub.chatAttachmentsGet(context.Background(), nil, ChatAttachmentsGetInput{ + ConversationID: saved.Conversation.ID, + }) + require.NoError(t, err) + require.Len(t, listed.Attachments, 1) + assert.Equal(t, attachments.Attachments[0].ID, listed.Attachments[0].ID) +} + func TestMCP_Good_ScreenWorkAreaAlias(t *testing.T) { c, err := core.New( core.WithService(screen.Register(&mockScreenPlatform{ diff --git a/pkg/mcp/subsystem.go b/pkg/mcp/subsystem.go index 1f51f84e..b20563f0 100644 --- a/pkg/mcp/subsystem.go +++ b/pkg/mcp/subsystem.go @@ -25,6 +25,7 @@ func New(c *core.Core) *Subsystem { func (s *Subsystem) Name() string { return "display" } func (s *Subsystem) RegisterTools(server *mcp.Server) { + s.registerChatTools(server) s.registerWebviewTools(server) s.registerWindowTools(server) s.registerLayoutTools(server) diff --git a/pkg/mcp/tools_chat.go b/pkg/mcp/tools_chat.go new file mode 100644 index 00000000..7185742d --- /dev/null +++ b/pkg/mcp/tools_chat.go @@ -0,0 +1,373 @@ +package mcp + +import ( + "context" + + "dappco.re/go/core/gui/pkg/display" + coreerr "forge.lthn.ai/core/go-log" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type ChatModelsInput struct{} +type ChatModelsOutput struct { + SelectedModel string `json:"selected_model"` + Models []display.ModelEntry `json:"models"` +} + +func (s *Subsystem) chatModels(_ context.Context, _ *mcp.CallToolRequest, _ ChatModelsInput) (*mcp.CallToolResult, ChatModelsOutput, error) { + result, _, err := s.core.QUERY(display.QueryChatModels{}) + if err != nil { + return nil, ChatModelsOutput{}, err + } + models, ok := result.([]display.ModelEntry) + if !ok { + return nil, ChatModelsOutput{}, coreerr.E("mcp.chatModels", "unexpected result type", nil) + } + selected := "" + for _, model := range models { + if model.Loaded { + selected = model.Name + break + } + } + return nil, ChatModelsOutput{SelectedModel: selected, Models: models}, nil +} + +type ChatSelectModelInput struct { + Model string `json:"model"` +} + +func (s *Subsystem) chatSelectModel(_ context.Context, _ *mcp.CallToolRequest, input ChatSelectModelInput) (*mcp.CallToolResult, ChatModelsOutput, error) { + result, _, err := s.core.PERFORM(display.TaskSelectModel{Model: input.Model}) + if err != nil { + return nil, ChatModelsOutput{}, err + } + models, ok := result.([]display.ModelEntry) + if !ok { + return nil, ChatModelsOutput{}, coreerr.E("mcp.chatSelectModel", "unexpected result type", nil) + } + selected := "" + for _, model := range models { + if model.Loaded { + selected = model.Name + break + } + } + return nil, ChatModelsOutput{SelectedModel: selected, Models: models}, nil +} + +type ChatSettingsGetInput struct{} +type ChatSettingsOutput struct { + Settings display.ChatSettings `json:"settings"` +} + +func (s *Subsystem) chatSettingsGet(_ context.Context, _ *mcp.CallToolRequest, _ ChatSettingsGetInput) (*mcp.CallToolResult, ChatSettingsOutput, error) { + result, _, err := s.core.QUERY(display.QueryChatSettingsLoad{}) + if err != nil { + return nil, ChatSettingsOutput{}, err + } + settings, ok := result.(display.ChatSettings) + if !ok { + return nil, ChatSettingsOutput{}, coreerr.E("mcp.chatSettingsGet", "unexpected result type", nil) + } + return nil, ChatSettingsOutput{Settings: settings}, nil +} + +type ChatSettingsSetInput struct { + Settings display.ChatSettings `json:"settings"` +} + +func (s *Subsystem) chatSettingsSet(_ context.Context, _ *mcp.CallToolRequest, input ChatSettingsSetInput) (*mcp.CallToolResult, ChatSettingsOutput, error) { + result, _, err := s.core.PERFORM(display.TaskChatSettingsSave{Settings: input.Settings}) + if err != nil { + return nil, ChatSettingsOutput{}, err + } + settings, ok := result.(display.ChatSettings) + if !ok { + return nil, ChatSettingsOutput{}, coreerr.E("mcp.chatSettingsSet", "unexpected result type", nil) + } + return nil, ChatSettingsOutput{Settings: settings}, nil +} + +type ChatSettingsResetInput struct{} + +func (s *Subsystem) chatSettingsReset(_ context.Context, _ *mcp.CallToolRequest, _ ChatSettingsResetInput) (*mcp.CallToolResult, ChatSettingsOutput, error) { + result, _, err := s.core.PERFORM(display.TaskChatSettingsReset{}) + if err != nil { + return nil, ChatSettingsOutput{}, err + } + settings, ok := result.(display.ChatSettings) + if !ok { + return nil, ChatSettingsOutput{}, coreerr.E("mcp.chatSettingsReset", "unexpected result type", nil) + } + return nil, ChatSettingsOutput{Settings: settings}, nil +} + +type ChatConversationNewInput struct{} +type ChatConversationOutput struct { + Conversation display.Conversation `json:"conversation"` +} + +func (s *Subsystem) chatConversationNew(_ context.Context, _ *mcp.CallToolRequest, _ ChatConversationNewInput) (*mcp.CallToolResult, ChatConversationOutput, error) { + result, _, err := s.core.PERFORM(display.TaskConversationNew{}) + if err != nil { + return nil, ChatConversationOutput{}, err + } + conv, ok := result.(display.Conversation) + if !ok { + return nil, ChatConversationOutput{}, coreerr.E("mcp.chatConversationNew", "unexpected result type", nil) + } + return nil, ChatConversationOutput{Conversation: conv}, nil +} + +type ChatConversationSaveInput struct { + Conversation display.Conversation `json:"conversation"` +} + +func (s *Subsystem) chatConversationSave(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationSaveInput) (*mcp.CallToolResult, ChatConversationOutput, error) { + result, _, err := s.core.PERFORM(display.TaskConversationSave{Conversation: input.Conversation}) + if err != nil { + return nil, ChatConversationOutput{}, err + } + conv, ok := result.(display.Conversation) + if !ok { + return nil, ChatConversationOutput{}, coreerr.E("mcp.chatConversationSave", "unexpected result type", nil) + } + return nil, ChatConversationOutput{Conversation: conv}, nil +} + +type ChatConversationGetInput struct { + ID string `json:"id"` +} + +func (s *Subsystem) chatConversationGet(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationGetInput) (*mcp.CallToolResult, ChatConversationOutput, error) { + result, _, err := s.core.QUERY(display.QueryConversationGet{ID: input.ID}) + if err != nil { + return nil, ChatConversationOutput{}, err + } + conv, ok := result.(display.Conversation) + if !ok { + return nil, ChatConversationOutput{}, coreerr.E("mcp.chatConversationGet", "unexpected result type", nil) + } + return nil, ChatConversationOutput{Conversation: conv}, nil +} + +type ChatConversationsListInput struct{} +type ChatConversationsOutput struct { + Conversations []display.Conversation `json:"conversations"` +} + +func (s *Subsystem) chatConversationsList(_ context.Context, _ *mcp.CallToolRequest, _ ChatConversationsListInput) (*mcp.CallToolResult, ChatConversationsOutput, error) { + result, _, err := s.core.QUERY(display.QueryConversationsList{}) + if err != nil { + return nil, ChatConversationsOutput{}, err + } + conversations, ok := result.([]display.Conversation) + if !ok { + return nil, ChatConversationsOutput{}, coreerr.E("mcp.chatConversationsList", "unexpected result type", nil) + } + return nil, ChatConversationsOutput{Conversations: conversations}, nil +} + +type ChatConversationsSearchInput struct { + Query string `json:"q"` +} + +func (s *Subsystem) chatConversationsSearch(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsSearchInput) (*mcp.CallToolResult, ChatConversationsOutput, error) { + result, _, err := s.core.QUERY(display.QueryConversationsSearch{Query: input.Query}) + if err != nil { + return nil, ChatConversationsOutput{}, err + } + conversations, ok := result.([]display.Conversation) + if !ok { + return nil, ChatConversationsOutput{}, coreerr.E("mcp.chatConversationsSearch", "unexpected result type", nil) + } + return nil, ChatConversationsOutput{Conversations: conversations}, nil +} + +type ChatConversationRenameInput struct { + ID string `json:"id"` + Title string `json:"title"` +} + +func (s *Subsystem) chatConversationRename(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationRenameInput) (*mcp.CallToolResult, ChatConversationOutput, error) { + result, _, err := s.core.PERFORM(display.TaskConversationRename{ID: input.ID, Title: input.Title}) + if err != nil { + return nil, ChatConversationOutput{}, err + } + conv, ok := result.(display.Conversation) + if !ok { + return nil, ChatConversationOutput{}, coreerr.E("mcp.chatConversationRename", "unexpected result type", nil) + } + return nil, ChatConversationOutput{Conversation: conv}, nil +} + +type ChatConversationDeleteInput struct { + ID string `json:"id"` +} +type ChatConversationDeleteOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) chatConversationDelete(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationDeleteInput) (*mcp.CallToolResult, ChatConversationDeleteOutput, error) { + _, _, err := s.core.PERFORM(display.TaskConversationDelete{ID: input.ID}) + if err != nil { + return nil, ChatConversationDeleteOutput{}, err + } + return nil, ChatConversationDeleteOutput{Success: true}, nil +} + +type ChatConversationExportInput struct { + ID string `json:"id"` +} +type ChatConversationExportOutput struct { + Markdown string `json:"markdown"` +} + +func (s *Subsystem) chatConversationExport(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationExportInput) (*mcp.CallToolResult, ChatConversationExportOutput, error) { + result, _, err := s.core.QUERY(display.QueryConversationExport{ID: input.ID}) + if err != nil { + return nil, ChatConversationExportOutput{}, err + } + markdown, ok := result.(string) + if !ok { + return nil, ChatConversationExportOutput{}, coreerr.E("mcp.chatConversationExport", "unexpected result type", nil) + } + return nil, ChatConversationExportOutput{Markdown: markdown}, nil +} + +type ChatHistoryInput struct { + ConversationID string `json:"conversation_id"` +} +type ChatHistoryOutput struct { + Messages []display.ChatMessage `json:"messages"` +} + +func (s *Subsystem) chatHistory(_ context.Context, _ *mcp.CallToolRequest, input ChatHistoryInput) (*mcp.CallToolResult, ChatHistoryOutput, error) { + result, _, err := s.core.QUERY(display.QueryChatHistory{ConversationID: input.ConversationID}) + if err != nil { + return nil, ChatHistoryOutput{}, err + } + messages, ok := result.([]display.ChatMessage) + if !ok { + return nil, ChatHistoryOutput{}, coreerr.E("mcp.chatHistory", "unexpected result type", nil) + } + return nil, ChatHistoryOutput{Messages: messages}, nil +} + +type ChatSendInput struct { + ConversationID string `json:"conversation_id"` + Content string `json:"content"` +} + +func (s *Subsystem) chatSend(_ context.Context, _ *mcp.CallToolRequest, input ChatSendInput) (*mcp.CallToolResult, ChatConversationOutput, error) { + result, _, err := s.core.PERFORM(display.TaskChatSend{ + ConversationID: input.ConversationID, + Content: input.Content, + }) + if err != nil { + return nil, ChatConversationOutput{}, err + } + conv, ok := result.(display.Conversation) + if !ok { + return nil, ChatConversationOutput{}, coreerr.E("mcp.chatSend", "unexpected result type", nil) + } + return nil, ChatConversationOutput{Conversation: conv}, nil +} + +type ChatClearInput struct { + ConversationID string `json:"conversation_id"` +} + +func (s *Subsystem) chatClear(_ context.Context, _ *mcp.CallToolRequest, input ChatClearInput) (*mcp.CallToolResult, ChatConversationOutput, error) { + result, _, err := s.core.PERFORM(display.TaskChatClear{ConversationID: input.ConversationID}) + if err != nil { + return nil, ChatConversationOutput{}, err + } + conv, ok := result.(display.Conversation) + if !ok { + return nil, ChatConversationOutput{}, coreerr.E("mcp.chatClear", "unexpected result type", nil) + } + return nil, ChatConversationOutput{Conversation: conv}, nil +} + +type ChatAttachmentsGetInput struct { + ConversationID string `json:"conversation_id"` +} +type ChatAttachmentsOutput struct { + Attachments []display.ImageAttachment `json:"attachments"` +} + +func (s *Subsystem) chatAttachmentsGet(_ context.Context, _ *mcp.CallToolRequest, input ChatAttachmentsGetInput) (*mcp.CallToolResult, ChatAttachmentsOutput, error) { + result, _, err := s.core.QUERY(display.QueryQueuedImages{ConversationID: input.ConversationID}) + if err != nil { + return nil, ChatAttachmentsOutput{}, err + } + attachments, ok := result.([]display.ImageAttachment) + if !ok { + return nil, ChatAttachmentsOutput{}, coreerr.E("mcp.chatAttachmentsGet", "unexpected result type", nil) + } + return nil, ChatAttachmentsOutput{Attachments: attachments}, nil +} + +type ChatAttachImageInput struct { + ConversationID string `json:"conversation_id"` + Attachment display.ImageAttachment `json:"attachment"` +} + +func (s *Subsystem) chatAttachImage(_ context.Context, _ *mcp.CallToolRequest, input ChatAttachImageInput) (*mcp.CallToolResult, ChatAttachmentsOutput, error) { + result, _, err := s.core.PERFORM(display.TaskAttachImage{ + ConversationID: input.ConversationID, + Attachment: input.Attachment, + }) + if err != nil { + return nil, ChatAttachmentsOutput{}, err + } + attachments, ok := result.([]display.ImageAttachment) + if !ok { + return nil, ChatAttachmentsOutput{}, coreerr.E("mcp.chatAttachImage", "unexpected result type", nil) + } + return nil, ChatAttachmentsOutput{Attachments: attachments}, nil +} + +type ChatDetachImageInput struct { + ConversationID string `json:"conversation_id"` + AttachmentID string `json:"attachment_id"` +} + +func (s *Subsystem) chatDetachImage(_ context.Context, _ *mcp.CallToolRequest, input ChatDetachImageInput) (*mcp.CallToolResult, ChatAttachmentsOutput, error) { + result, _, err := s.core.PERFORM(display.TaskDetachImage{ + ConversationID: input.ConversationID, + AttachmentID: input.AttachmentID, + }) + if err != nil { + return nil, ChatAttachmentsOutput{}, err + } + attachments, ok := result.([]display.ImageAttachment) + if !ok { + return nil, ChatAttachmentsOutput{}, coreerr.E("mcp.chatDetachImage", "unexpected result type", nil) + } + return nil, ChatAttachmentsOutput{Attachments: attachments}, nil +} + +func (s *Subsystem) registerChatTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{Name: "chat_models", Description: "List locally available chat models and the currently selected model"}, s.chatModels) + mcp.AddTool(server, &mcp.Tool{Name: "chat_select_model", Description: "Select the active local chat model"}, s.chatSelectModel) + mcp.AddTool(server, &mcp.Tool{Name: "chat_settings_get", Description: "Load persisted chat settings"}, s.chatSettingsGet) + mcp.AddTool(server, &mcp.Tool{Name: "chat_settings_set", Description: "Persist chat settings defaults"}, s.chatSettingsSet) + mcp.AddTool(server, &mcp.Tool{Name: "chat_settings_reset", Description: "Reset chat settings to RFC defaults"}, s.chatSettingsReset) + mcp.AddTool(server, &mcp.Tool{Name: "chat_conversation_new", Description: "Create a new chat conversation"}, s.chatConversationNew) + mcp.AddTool(server, &mcp.Tool{Name: "chat_conversation_save", Description: "Save or import a full chat conversation"}, s.chatConversationSave) + mcp.AddTool(server, &mcp.Tool{Name: "chat_conversation_get", Description: "Load a specific chat conversation"}, s.chatConversationGet) + mcp.AddTool(server, &mcp.Tool{Name: "chat_conversations_list", Description: "List saved chat conversations"}, s.chatConversationsList) + mcp.AddTool(server, &mcp.Tool{Name: "chat_conversations_search", Description: "Search chat conversations by title or content"}, s.chatConversationsSearch) + mcp.AddTool(server, &mcp.Tool{Name: "chat_conversation_rename", Description: "Rename a chat conversation"}, s.chatConversationRename) + mcp.AddTool(server, &mcp.Tool{Name: "chat_conversation_delete", Description: "Delete a chat conversation"}, s.chatConversationDelete) + mcp.AddTool(server, &mcp.Tool{Name: "chat_conversation_export", Description: "Export a chat conversation as Markdown"}, s.chatConversationExport) + mcp.AddTool(server, &mcp.Tool{Name: "chat_history", Description: "Return the ordered message history for a conversation"}, s.chatHistory) + mcp.AddTool(server, &mcp.Tool{Name: "chat_send", Description: "Append a user message and create the paired assistant response placeholder"}, s.chatSend) + mcp.AddTool(server, &mcp.Tool{Name: "chat_clear", Description: "Clear all messages from a conversation"}, s.chatClear) + mcp.AddTool(server, &mcp.Tool{Name: "chat_attachments_get", Description: "List queued image attachments for the next message"}, s.chatAttachmentsGet) + mcp.AddTool(server, &mcp.Tool{Name: "chat_attach_image", Description: "Queue an image attachment for the next chat message"}, s.chatAttachImage) + mcp.AddTool(server, &mcp.Tool{Name: "chat_detach_image", Description: "Remove a queued image attachment"}, s.chatDetachImage) +}