Implement RFC chat gaps in display and MCP

This commit is contained in:
Claude 2026-04-14 15:04:23 +01:00
parent c38711ec7d
commit 4bc4db544f
6 changed files with 919 additions and 9 deletions

View file

@ -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 = &copySettings
}
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)
}
}

View file

@ -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")
}

View file

@ -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)

View file

@ -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{

View file

@ -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)

373
pkg/mcp/tools_chat.go Normal file
View file

@ -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)
}