Implement RFC chat gaps in display and MCP
This commit is contained in:
parent
c38711ec7d
commit
4bc4db544f
6 changed files with 919 additions and 9 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
373
pkg/mcp/tools_chat.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue