diff --git a/pkg/chat/messages.go b/pkg/chat/messages.go index 77789476..8523511f 100644 --- a/pkg/chat/messages.go +++ b/pkg/chat/messages.go @@ -24,7 +24,8 @@ type QueryConversationSearch struct { Query string `json:"q"` } -type ChatMessage struct { +// Message is the persisted chat transcript entry used by the MVP IPC surface. +type Message struct { ID string `json:"id"` Role string `json:"role"` Content string `json:"content"` @@ -38,16 +39,23 @@ type ChatMessage struct { FinishReason string `json:"finish_reason,omitempty"` } -type ModelEntry struct { +type ChatMessage = Message + +// Model is the transport shape exposed by gui.chat.models. +type Model struct { Name string `json:"name"` - Architecture string `json:"architecture"` - QuantBits int `json:"quant_bits"` - SizeBytes int64 `json:"size_bytes"` - Loaded bool `json:"loaded"` - Backend string `json:"backend"` - SupportsVision bool `json:"supports_vision"` + Size int64 `json:"size"` + Status string `json:"status"` + Architecture string `json:"architecture,omitempty"` + QuantBits int `json:"quant_bits,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + Loaded bool `json:"loaded,omitempty"` + Backend string `json:"backend,omitempty"` + SupportsVision bool `json:"supports_vision,omitempty"` } +type ModelEntry = Model + type ChatSettings struct { Temperature float32 `json:"temperature"` TopP float32 `json:"top_p"` @@ -64,7 +72,7 @@ type Conversation struct { Model string `json:"model"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - Messages []ChatMessage `json:"messages"` + Messages []Message `json:"messages"` Settings *ChatSettings `json:"settings,omitempty"` } @@ -139,8 +147,8 @@ type ActionConversationDeleted struct { } type ActionMessageAdded struct { - ConversationID string `json:"conversation_id"` - Message ChatMessage `json:"message"` + ConversationID string `json:"conversation_id"` + Message Message `json:"message"` } type ActionConversationCleared struct { diff --git a/pkg/chat/service.go b/pkg/chat/service.go index 00fb5baa..3e801d77 100644 --- a/pkg/chat/service.go +++ b/pkg/chat/service.go @@ -44,6 +44,20 @@ type Options struct { Now func() time.Time } +type contract interface { + Send(context.Context, sendInput) (string, error) + History(string, int) ([]Message, error) + Models() []ModelEntry + SelectModel(selectModelInput) (ChatSettings, error) + ListConversations() ([]Conversation, error) + LoadConversation(string) (Conversation, error) + DeleteConversation(string) error + StartThinking(thinkingInput) (ThinkingState, error) + StopThinking(thinkingInput) (ThinkingState, error) +} + +var _ contract = (*Service)(nil) + type Service struct { *core.ServiceRuntime[Options] options Options @@ -52,12 +66,20 @@ type Service struct { toolExecutor ToolExecutor toolHandler *ToolCallHandler pendingAttachments map[string][]ImageAttachment + thinkingStates map[string]ThinkingState mu sync.Mutex } type sendInput struct { ConversationID string `json:"conversation_id,omitempty"` Content string `json:"content"` + Model string `json:"model,omitempty"` +} + +type historyInput struct { + ID string `json:"id,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + Limit int `json:"limit,omitempty"` } type conversationInput struct { @@ -83,6 +105,7 @@ type thinkingInput struct { } type selectModelInput struct { + Name string `json:"name,omitempty"` Model string `json:"model"` ConversationID string `json:"conversation_id,omitempty"` ID string `json:"id,omitempty"` @@ -180,6 +203,7 @@ func Register(optionFns ...func(*Options)) func(*core.Core) core.Result { options: options, httpClient: options.HTTPClient, pendingAttachments: make(map[string][]ImageAttachment), + thinkingStates: make(map[string]ThinkingState), } return core.Result{Value: svc, OK: true} } @@ -219,7 +243,7 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { conv, err := s.getConversation(typed.ID, typed.ConversationID) return core.Result{}.New(conv, err) case QueryModels: - return core.Result{Value: s.discoverModels(), OK: true} + return core.Result{Value: s.Models(), OK: true} case QuerySettings: return core.Result{Value: s.loadSettings(), OK: true} case QuerySettingsDefaults: @@ -245,8 +269,8 @@ func (s *Service) registerActions() { if err != nil { return core.Result{Value: err, OK: false} } - conv, err := s.send(ctx, input) - return core.Result{}.New(conv, err) + messageID, err := s.Send(ctx, input) + return core.Result{}.New(messageID, err) }) c.Action("gui.chat.clear", func(_ context.Context, opts core.Options) core.Result { input, err := decodeInput[conversationInput](opts) @@ -257,22 +281,22 @@ func (s *Service) registerActions() { return core.Result{}.New(conv, err) }) c.Action("gui.chat.history", func(_ context.Context, opts core.Options) core.Result { - input, err := decodeInput[conversationInput](opts) + input, err := decodeInput[historyInput](opts) if err != nil { return core.Result{Value: err, OK: false} } - conv, err := s.getConversation(input.ID, input.ConversationID) - return core.Result{}.New(conv, err) + messages, err := s.History(coalesce(input.ID, input.ConversationID), input.Limit) + return core.Result{}.New(messages, err) }) c.Action("gui.chat.models", func(_ context.Context, _ core.Options) core.Result { - return core.Result{Value: s.discoverModels(), OK: true} + return core.Result{Value: s.Models(), OK: true} }) c.Action("gui.chat.selectModel", func(_ context.Context, opts core.Options) core.Result { input, err := decodeInput[selectModelInput](opts) if err != nil { return core.Result{Value: err, OK: false} } - settings, err := s.selectModel(input) + settings, err := s.SelectModel(input) return core.Result{}.New(settings, err) }) c.Action("gui.chat.settings.save", func(_ context.Context, opts core.Options) core.Result { @@ -295,24 +319,26 @@ func (s *Service) registerActions() { return core.Result{}.New(settings, err) }) c.Action("gui.chat.conversations.list", func(_ context.Context, _ core.Options) core.Result { - conversations, err := s.listConversationSummaries() + conversations, err := s.ListConversations() return core.Result{}.New(conversations, err) }) - c.Action("gui.chat.conversations.get", func(_ context.Context, opts core.Options) core.Result { + loadConversation := func(_ context.Context, opts core.Options) core.Result { input, err := decodeInput[conversationInput](opts) if err != nil { return core.Result{Value: err, OK: false} } - conv, err := s.getConversation(input.ID, input.ConversationID) + conv, err := s.LoadConversation(coalesce(input.ID, input.ConversationID)) return core.Result{}.New(conv, err) - }) + } + c.Action("gui.chat.conversations.load", loadConversation) + c.Action("gui.chat.conversations.get", loadConversation) c.Action("gui.chat.conversations.delete", func(_ context.Context, opts core.Options) core.Result { input, err := decodeInput[conversationInput](opts) if err != nil { return core.Result{Value: err, OK: false} } - err = s.deleteConversation(coalesce(input.ID, input.ConversationID)) - return core.Result{}.New(nil, err) + err = s.DeleteConversation(coalesce(input.ID, input.ConversationID)) + return core.Result{}.New(true, err) }) c.Action("gui.chat.conversations.search", func(_ context.Context, opts core.Options) core.Result { input, err := decodeInput[searchInput](opts) @@ -393,15 +419,8 @@ func (s *Service) registerActions() { if err != nil { return core.Result{Value: err, OK: false} } - if strings.TrimSpace(input.ConversationID) == "" { - return core.Result{Value: coreerr.E("chat.thinking.start", "conversation id is required", nil), OK: false} - } - s.emit(ActionThinkingStarted{ - ConversationID: input.ConversationID, - MessageID: input.MessageID, - StartedAt: input.StartedAt, - }) - return core.Result{Value: input, OK: true} + state, err := s.StartThinking(input) + return core.Result{}.New(state, err) }) c.Action("gui.chat.thinking.append", func(_ context.Context, opts core.Options) core.Result { input, err := decodeInput[thinkingInput](opts) @@ -416,30 +435,19 @@ func (s *Service) registerActions() { MessageID: input.MessageID, Content: input.Content, }) + s.appendThinking(input.ConversationID, input.Content) return core.Result{Value: input.Content, OK: true} }) - c.Action("gui.chat.thinking.end", func(_ context.Context, opts core.Options) core.Result { + stopThinking := func(_ context.Context, opts core.Options) core.Result { input, err := decodeInput[thinkingInput](opts) if err != nil { return core.Result{Value: err, OK: false} } - if strings.TrimSpace(input.ConversationID) == "" { - return core.Result{Value: coreerr.E("chat.thinking.end", "conversation id is required", nil), OK: false} - } - duration := input.DurationMS - if duration == 0 && !input.StartedAt.IsZero() { - duration = time.Since(input.StartedAt).Milliseconds() - if duration < 0 { - duration = 0 - } - } - s.emit(ActionThinkingEnded{ - ConversationID: input.ConversationID, - MessageID: input.MessageID, - DurationMS: duration, - }) - return core.Result{Value: duration, OK: true} - }) + state, err := s.StopThinking(input) + return core.Result{}.New(state, err) + } + c.Action("gui.chat.thinking.stop", stopThinking) + c.Action("gui.chat.thinking.end", stopThinking) } func decodeInput[T any](opts core.Options) (T, error) { @@ -483,6 +491,51 @@ func (s *Service) now() time.Time { return time.Now() } +func (s *Service) Send(ctx context.Context, input sendInput) (string, error) { + return s.send(ctx, input) +} + +func (s *Service) History(conversationID string, limit int) ([]Message, error) { + if limit < 0 { + return nil, coreerr.E("chat.history", "limit must be greater than or equal to zero", nil) + } + conv, err := s.LoadConversation(conversationID) + if err != nil { + return nil, err + } + if limit == 0 || limit >= len(conv.Messages) { + return slices.Clone(conv.Messages), nil + } + return slices.Clone(conv.Messages[len(conv.Messages)-limit:]), nil +} + +func (s *Service) Models() []ModelEntry { + models := s.discoverModels() + activeName := s.resolveModel("", s.loadSettings().DefaultModel) + if len(models) == 0 { + return []ModelEntry{{ + Name: activeName, + Size: 0, + Status: "active", + Loaded: true, + }} + } + + for index := range models { + models[index].Size = models[index].SizeBytes + switch { + case strings.EqualFold(models[index].Name, activeName): + models[index].Loaded = true + models[index].Status = "active" + case models[index].Loaded: + models[index].Status = "loaded" + default: + models[index].Status = "available" + } + } + return models +} + func (s *Service) saveSettings(settings ChatSettings) error { if err := s.validateSettings(settings); err != nil { return err @@ -504,12 +557,13 @@ func (s *Service) loadSettings() ChatSettings { return settings } -func (s *Service) selectModel(input selectModelInput) (ChatSettings, error) { - if err := s.validateModelName(input.Model); err != nil { +func (s *Service) SelectModel(input selectModelInput) (ChatSettings, error) { + modelName := coalesce(input.Name, input.Model) + if err := s.validateModelName(modelName); err != nil { return ChatSettings{}, err } settings := s.loadSettings() - settings.DefaultModel = input.Model + settings.DefaultModel = modelName if err := s.saveSettings(settings); err != nil { return ChatSettings{}, err } @@ -523,7 +577,7 @@ func (s *Service) selectModel(input selectModelInput) (ChatSettings, error) { if err != nil { return ChatSettings{}, err } - conv.Model = input.Model + conv.Model = modelName conv, err = s.saveConversation(conv) if err != nil { return ChatSettings{}, err @@ -532,6 +586,97 @@ func (s *Service) selectModel(input selectModelInput) (ChatSettings, error) { return settings, nil } +func (s *Service) ListConversations() ([]Conversation, error) { + return s.listConversations() +} + +func (s *Service) LoadConversation(id string) (Conversation, error) { + return s.getConversation(id, "") +} + +func (s *Service) DeleteConversation(id string) error { + return s.deleteConversation(id) +} + +func (s *Service) StartThinking(input thinkingInput) (ThinkingState, error) { + if strings.TrimSpace(input.ConversationID) == "" { + return ThinkingState{}, coreerr.E("chat.thinking.start", "conversation id is required", nil) + } + + state := ThinkingState{ + Active: true, + StartedAt: input.StartedAt, + } + if state.StartedAt.IsZero() { + state.StartedAt = s.now() + } + + s.mu.Lock() + s.thinkingStates[input.ConversationID] = state + s.mu.Unlock() + + s.emit(ActionThinkingStarted{ + ConversationID: input.ConversationID, + MessageID: input.MessageID, + StartedAt: state.StartedAt, + }) + return state, nil +} + +func (s *Service) StopThinking(input thinkingInput) (ThinkingState, error) { + if strings.TrimSpace(input.ConversationID) == "" { + return ThinkingState{}, coreerr.E("chat.thinking.stop", "conversation id is required", nil) + } + + s.mu.Lock() + state, ok := s.thinkingStates[input.ConversationID] + delete(s.thinkingStates, input.ConversationID) + s.mu.Unlock() + + if !ok && !input.StartedAt.IsZero() { + state.StartedAt = input.StartedAt + } + if state.StartedAt.IsZero() { + state.StartedAt = s.now() + } + + state.Active = false + state.Content = strings.TrimSpace(state.Content) + state.EndedAt = s.now() + state.DurationMS = input.DurationMS + if state.DurationMS == 0 { + state.DurationMS = state.EndedAt.Sub(state.StartedAt).Milliseconds() + if state.DurationMS < 0 { + state.DurationMS = 0 + } + } + + s.emit(ActionThinkingEnded{ + ConversationID: input.ConversationID, + MessageID: input.MessageID, + DurationMS: state.DurationMS, + }) + return state, nil +} + +func (s *Service) appendThinking(conversationID, content string) { + key := strings.TrimSpace(conversationID) + if key == "" || strings.TrimSpace(content) == "" { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + state := s.thinkingStates[key] + if state.StartedAt.IsZero() { + state.StartedAt = s.now() + } + state.Active = true + state.Content += content + s.thinkingStates[key] = state +} + func (s *Service) saveConversation(conv Conversation) (Conversation, error) { if err := s.validateConversation(conv); err != nil { return Conversation{}, err @@ -567,7 +712,7 @@ func (s *Service) loadConversation(id string) (Conversation, error) { return conv, nil } -func (s *Service) listConversationSummaries() ([]ConversationSummary, error) { +func (s *Service) listConversations() ([]Conversation, error) { if s.store == nil { return nil, nil } @@ -575,13 +720,30 @@ func (s *Service) listConversationSummaries() ([]ConversationSummary, error) { if err != nil { return nil, err } - summaries := make([]ConversationSummary, 0, len(items)) + + conversations := make([]Conversation, 0, len(items)) for _, payload := range items { var conv Conversation if result := core.JSONUnmarshalString(payload, &conv); result.OK { - summaries = append(summaries, conv.Summary()) + conversations = append(conversations, conv) } } + sort.Slice(conversations, func(i, j int) bool { + return conversations[i].UpdatedAt.After(conversations[j].UpdatedAt) + }) + return conversations, nil +} + +func (s *Service) listConversationSummaries() ([]ConversationSummary, error) { + conversations, err := s.listConversations() + if err != nil { + return nil, err + } + + summaries := make([]ConversationSummary, 0, len(conversations)) + for _, conv := range conversations { + summaries = append(summaries, conv.Summary()) + } sort.Slice(summaries, func(i, j int) bool { return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt) }) @@ -851,16 +1013,17 @@ func (s *Service) mergedSettings(global ChatSettings, override *ChatSettings) Ch return merged } -func (s *Service) send(ctx context.Context, input sendInput) (Conversation, error) { +func (s *Service) send(ctx context.Context, input sendInput) (string, error) { if strings.TrimSpace(input.Content) == "" && !s.hasPendingAttachments(input.ConversationID) { - return Conversation{}, coreerr.E("chat.send", "message content is required", nil) + return "", coreerr.E("chat.send", "message content is required", nil) } settings := s.loadSettings() var ( - conv Conversation - err error - created bool + conv Conversation + err error + created bool + lastAssistantMessageID string ) if input.ConversationID != "" { conv, err = s.loadConversation(input.ConversationID) @@ -869,7 +1032,7 @@ func (s *Service) send(ctx context.Context, input sendInput) (Conversation, erro created = true } if err != nil { - return Conversation{}, err + return "", err } attachments := s.drainAttachments(conv.ID) @@ -878,6 +1041,12 @@ func (s *Service) send(ctx context.Context, input sendInput) (Conversation, erro } now := s.now() + if modelName := strings.TrimSpace(input.Model); modelName != "" { + if err := s.validateModelName(modelName); err != nil { + return "", err + } + conv.Model = modelName + } conv.Model = s.resolveModel(conv.Model, settings.DefaultModel) userMessage := ChatMessage{ ID: "msg-" + strconv.FormatInt(now.UnixNano(), 36), @@ -893,7 +1062,7 @@ func (s *Service) send(ctx context.Context, input sendInput) (Conversation, erro } conv, err = s.saveConversation(conv) if err != nil { - return Conversation{}, err + return "", err } s.emit(ActionMessageAdded{ConversationID: conv.ID, Message: userMessage}) s.emit(ActionConversationUpdated{Conversation: conv}) @@ -902,18 +1071,19 @@ func (s *Service) send(ctx context.Context, input sendInput) (Conversation, erro effectiveSettings := s.mergedSettings(settings, conv.Settings) conv.Model = s.resolveModel(conv.Model, effectiveSettings.DefaultModel) if err := s.validateAttachmentsForModel(conv.Model, attachmentsForConversationTurn(conv.Messages)); err != nil { - return conv, err + return "", err } assistantMessage, err := s.streamAssistant(ctx, conv, effectiveSettings) if err != nil { - return conv, err + return "", err } + lastAssistantMessageID = assistantMessage.ID if hasRenderableContent(assistantMessage) { conv.Messages = append(conv.Messages, assistantMessage) conv, err = s.saveConversation(conv) if err != nil { - return conv, err + return "", err } s.emit(ActionMessageAdded{ConversationID: conv.ID, Message: assistantMessage}) s.emit(ActionConversationUpdated{Conversation: conv}) @@ -940,12 +1110,15 @@ func (s *Service) send(ctx context.Context, input sendInput) (Conversation, erro } conv, err = s.saveConversation(conv) if err != nil { - return conv, err + return "", err } s.emit(ActionConversationUpdated{Conversation: conv}) } - return conv, nil + if lastAssistantMessageID == "" { + lastAssistantMessageID = userMessage.ID + } + return lastAssistantMessageID, nil } func (s *Service) hasPendingAttachments(conversationID string) bool { @@ -982,6 +1155,7 @@ func (s *Service) streamAssistant(ctx context.Context, conv Conversation, settin return ChatMessage{}, coreerr.E("chat.streamAssistant", strings.TrimSpace(string(body)), nil) } + // TODO(mantis-14): switch these callbacks to a dedicated GUI stream group when one exists. renderer := NewStreamRenderer(StreamCallbacks{ OnStart: func(streamID string) { s.emit(ActionStreamStarted{ConversationID: conv.ID, MessageID: messageID, StreamID: streamID}) @@ -1185,9 +1359,24 @@ func (s *Service) discoverModels() []ModelEntry { names = append(names, name) } slices.Sort(names) + activeModel := strings.TrimSpace(settings.DefaultModel) + if activeModel == "" && len(names) > 0 { + activeModel = names[0] + } results := make([]ModelEntry, 0, len(names)) for _, name := range names { - results = append(results, models[name]) + entry := models[name] + entry.Size = entry.SizeBytes + switch { + case strings.EqualFold(entry.Name, activeModel): + entry.Loaded = true + entry.Status = "active" + case entry.Loaded: + entry.Status = "loaded" + default: + entry.Status = "available" + } + results = append(results, entry) } return results } @@ -1325,11 +1514,13 @@ func discoverModelsOnDisk(root string) []ModelEntry { for discovered := range inference.Discover(root) { modelPath := discovered.Path name := filepath.Base(modelPath) + size := directorySize(modelPath) results = append(results, ModelEntry{ Name: name, + Size: size, Architecture: strings.ToLower(discovered.ModelType), QuantBits: coalesceQuantBits(discovered.QuantBits, quantBitsFromName(name)), - SizeBytes: directorySize(modelPath), + SizeBytes: size, Backend: "local", SupportsVision: architectureSupportsVision(discovered.ModelType), }) diff --git a/pkg/chat/service_example_test.go b/pkg/chat/service_example_test.go new file mode 100644 index 00000000..db4cecb3 --- /dev/null +++ b/pkg/chat/service_example_test.go @@ -0,0 +1,61 @@ +package chat + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "time" + + core "dappco.re/go/core" +) + +func ExampleRegister() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello from chat"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + })) + defer server.Close() + + storeDir, err := os.MkdirTemp("", "chat-example-*") + if err != nil { + panic(err) + } + defer os.RemoveAll(storeDir) + + c := core.New( + core.WithService(Register( + func(o *Options) { o.APIURL = server.URL }, + func(o *Options) { o.StorePath = filepath.Join(storeDir, "chat.db") }, + func(o *Options) { o.ToolExecutor = &mockToolExecutor{} }, + func(o *Options) { o.Now = func() time.Time { return time.Unix(1_700_000_000, 0).UTC() } }, + )), + core.WithServiceLock(), + ) + if !c.ServiceStartup(context.Background(), nil).OK { + panic("chat startup failed") + } + + send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Hello"}, + )) + if !send.OK { + panic(send.Value) + } + + conversations := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + history := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: conversations.Value.([]Conversation)[0].ID}, + )) + + fmt.Println(len(history.Value.([]Message))) + fmt.Println(history.Value.([]Message)[1].Content) + // Output: + // 2 + // Hello from chat +} diff --git a/pkg/chat/service_test.go b/pkg/chat/service_test.go index a20cb951..0331bfeb 100644 --- a/pkg/chat/service_test.go +++ b/pkg/chat/service_test.go @@ -7,7 +7,6 @@ import ( "net/http/httptest" "os" "path/filepath" - "strings" "testing" "time" @@ -71,76 +70,203 @@ func createDiscoveredModelRoot(t *testing.T, name, architecture string) string { return root } -func TestService_Good_SendAndHistory(t *testing.T) { - c := newChatCore(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n") - _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n") - _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n") - _, _ = io.WriteString(w, "data: [DONE]\n\n") - }, &mockToolExecutor{}) +func sequencedNow(start time.Time) func() time.Time { + current := start.Add(-time.Second) + return func() time.Time { + current = current.Add(time.Second) + return current + } +} + +func writeSSE(w http.ResponseWriter, payloads ...string) { + w.Header().Set("Content-Type", "text/event-stream") + for _, payload := range payloads { + _, _ = io.WriteString(w, "data: "+payload+"\n\n") + } +} + +func latestConversation(t *testing.T, c *core.Core) Conversation { + t.Helper() + result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + require.True(t, result.OK) + conversations, ok := result.Value.([]Conversation) + require.True(t, ok) + require.NotEmpty(t, conversations) + return conversations[0] +} + +func historyMessages(t *testing.T, c *core.Core, conversationID string, limit int) []Message { + t.Helper() + options := []core.Option{{ + Key: "conversation_id", + Value: conversationID, + }} + if limit > 0 { + options = append(options, core.Option{Key: "limit", Value: limit}) + } + result := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions(options...)) + require.True(t, result.OK) + messages, ok := result.Value.([]Message) + require.True(t, ok) + return messages +} + +func TestActionSend_Good_ReturnsAssistantMessageID(t *testing.T) { + modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3") + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello"}}]}`, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":" world"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} }) send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( core.Option{Key: "content", Value: "Hi"}, + core.Option{Key: "model", Value: "lemma"}, )) require.True(t, send.OK) - conv := send.Value.(Conversation) + messageID, ok := send.Value.(string) + require.True(t, ok) + require.NotEmpty(t, messageID) + + conv := latestConversation(t, c) require.Len(t, conv.Messages, 2) - assert.Equal(t, "user", conv.Messages[0].Role) - assert.Equal(t, "assistant", conv.Messages[1].Role) + assert.Equal(t, messageID, conv.Messages[1].ID) + assert.Equal(t, "lemma", conv.Model) assert.Equal(t, "Hello world", conv.Messages[1].Content) - - history := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions( - core.Option{Key: "conversation_id", Value: conv.ID}, - )) - require.True(t, history.OK) - assert.Equal(t, conv.ID, history.Value.(Conversation).ID) - - queryHistory := c.QUERY(QueryHistory{ConversationID: conv.ID}) - require.True(t, queryHistory.OK) - assert.Equal(t, conv.ID, queryHistory.Value.(Conversation).ID) } -func TestService_Good_ToolCallRoundTrip(t *testing.T) { - toolExecutor := &mockToolExecutor{} - requests := 0 - c := newChatCore(t, func(w http.ResponseWriter, r *http.Request) { - requests++ - w.Header().Set("Content-Type", "text/event-stream") - if requests == 1 { - _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"function\":{\"name\":\"layout_suggest\",\"arguments\":\"{\\\"window_count\\\":2}\"}}]}}]}\n\n") - _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"finish_reason\":\"tool_calls\"}]}\n\n") - _, _ = io.WriteString(w, "data: [DONE]\n\n") - return - } - _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-2\",\"choices\":[{\"delta\":{\"content\":\"Use a left-right split.\"}}]}\n\n") - _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-2\",\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n") - _, _ = io.WriteString(w, "data: [DONE]\n\n") - }, toolExecutor) +func TestActionSend_Bad_RejectsEmptyMessage(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions()) + require.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "message content is required") +} + +func TestActionSend_Ugly_PropagatesUpstreamFailure(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "model unavailable", http.StatusBadGateway) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Hi"}, + )) + require.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "model unavailable") +} + +func TestActionHistory_Good_HonoursLimit(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Alpha"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}) send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( - core.Option{Key: "content", Value: "Arrange these windows"}, + core.Option{Key: "content", Value: "One"}, )) require.True(t, send.OK) - conv := send.Value.(Conversation) - require.GreaterOrEqual(t, len(conv.Messages), 4) - require.Len(t, toolExecutor.calls, 1) - assert.Equal(t, "layout_suggest", toolExecutor.calls[0].Name) - assert.Equal(t, 2.0, toolExecutor.calls[0].Arguments["window_count"]) - assert.Equal(t, "tool", conv.Messages[2].Role) - assert.True(t, strings.Contains(conv.Messages[len(conv.Messages)-1].Content, "left-right")) + conv := latestConversation(t, c) + history := historyMessages(t, c, conv.ID, 1) + require.Len(t, history, 1) + assert.Equal(t, "assistant", history[0].Role) + assert.Equal(t, "Alpha", history[0].Content) } -func TestService_Good_SelectModelUpdatesConversation(t *testing.T) { - c := newChatCore(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n") - _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n") - _, _ = io.WriteString(w, "data: [DONE]\n\n") +func TestActionHistory_Bad_RequiresConversationID(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) }, &mockToolExecutor{}) + result := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions()) + require.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "conversation id is required") +} + +func TestActionHistory_Ugly_UnknownConversationFails(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "missing"}, + )) + require.False(t, result.OK) + require.Error(t, result.Value.(error)) +} + +func TestActionModels_Good_ReportsSizeAndStatus(t *testing.T) { + modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3") + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} }) + + result := c.Action("gui.chat.models").Run(context.Background(), core.NewOptions()) + require.True(t, result.OK) + + models, ok := result.Value.([]ModelEntry) + require.True(t, ok) + require.Len(t, models, 1) + assert.Equal(t, "lemma", models[0].Name) + assert.Equal(t, int64(4), models[0].Size) + assert.Equal(t, "active", models[0].Status) +} + +func TestActionModels_Bad_ReturnsFallbackWhenNothingDiscovered(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.models").Run(context.Background(), core.NewOptions()) + require.True(t, result.OK) + + models, ok := result.Value.([]ModelEntry) + require.True(t, ok) + require.Len(t, models, 1) + assert.Equal(t, "default", models[0].Name) + assert.Equal(t, "active", models[0].Status) +} + +func TestActionModels_Ugly_ReflectsSelectedModelStatus(t *testing.T) { + rootA := createDiscoveredModelRoot(t, "alpha", "gemma3") + rootB := createDiscoveredModelRoot(t, "beta", "gemma3") + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{rootA, rootB} }) + + selected := c.Action("gui.chat.selectModel").Run(context.Background(), core.NewOptions( + core.Option{Key: "model", Value: "beta"}, + )) + require.True(t, selected.OK) + + result := c.Action("gui.chat.models").Run(context.Background(), core.NewOptions()) + require.True(t, result.OK) + + models, ok := result.Value.([]ModelEntry) + require.True(t, ok) + require.Len(t, models, 2) + assert.Equal(t, "available", models[0].Status) + assert.Equal(t, "active", models[1].Status) +} + +func TestActionSelectModel_Good_UpdatesConversationAndSettings(t *testing.T) { + modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3") + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} }) + created := c.Action("gui.chat.conversations.new").Run(context.Background(), core.NewOptions()) require.True(t, created.OK) conv := created.Value.(Conversation) @@ -151,80 +277,278 @@ func TestService_Good_SelectModelUpdatesConversation(t *testing.T) { )) require.True(t, selected.OK) - updated := c.QUERY(QueryConversationGet{ConversationID: conv.ID}) - require.True(t, updated.OK) - assert.Equal(t, "lemma", updated.Value.(Conversation).Model) -} + settings := selected.Value.(ChatSettings) + assert.Equal(t, "lemma", settings.DefaultModel) -func TestService_Good_SettingsDefaults(t *testing.T) { - c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - _, _ = io.WriteString(w, "data: [DONE]\n\n") - }, &mockToolExecutor{}) - - result := c.QUERY(QuerySettingsDefaults{}) - require.True(t, result.OK) - assert.Equal(t, DefaultSettings(), result.Value.(ChatSettings)) - - actionResult := c.Action("gui.chat.settings.defaults").Run(context.Background(), core.Options{}) - require.True(t, actionResult.OK) - assert.Equal(t, DefaultSettings(), actionResult.Value.(ChatSettings)) -} - -func TestService_Bad_SettingsRejectOutOfRangeValues(t *testing.T) { - c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - _, _ = io.WriteString(w, "data: [DONE]\n\n") - }, &mockToolExecutor{}) - - result := c.Action("gui.chat.settings.save").Run(context.Background(), core.NewOptions( - core.Option{Key: "temperature", Value: float32(2.5)}, - core.Option{Key: "top_p", Value: float32(0.95)}, - core.Option{Key: "top_k", Value: 64}, - core.Option{Key: "max_tokens", Value: 2048}, - core.Option{Key: "context_window", Value: 8192}, - core.Option{Key: "system_prompt", Value: "You are a helpful assistant."}, + loaded := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: conv.ID}, )) + require.True(t, loaded.OK) + assert.Equal(t, "lemma", loaded.Value.(Conversation).Model) +} + +func TestActionSelectModel_Bad_RequiresModelName(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.selectModel").Run(context.Background(), core.NewOptions()) require.False(t, result.OK) require.Error(t, result.Value.(error)) - assert.Contains(t, result.Value.(error).Error(), "temperature must be between 0.0 and 2.0") + assert.Contains(t, result.Value.(error).Error(), "model is required") } -func TestService_Bad_SelectModelRejectsUnknownModel(t *testing.T) { - modelRoot := createDiscoveredModelRoot(t, "lemer", "gemma3") +func TestActionSelectModel_Ugly_RejectsUnknownDiscoveredModel(t *testing.T) { + modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3") c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - _, _ = io.WriteString(w, "data: [DONE]\n\n") + writeSSE(w, `[DONE]`) }, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} }) result := c.Action("gui.chat.selectModel").Run(context.Background(), core.NewOptions( - core.Option{Key: "model", Value: "missing-model"}, + core.Option{Key: "model", Value: "missing"}, )) require.False(t, result.OK) require.Error(t, result.Value.(error)) assert.Contains(t, result.Value.(error).Error(), "model is not available") } -func TestService_Bad_SendImageRejectsNonVisionModel(t *testing.T) { - modelRoot := createDiscoveredModelRoot(t, "lemma", "qwen3") +func TestActionConversationsList_Good_ReturnsNewestFirst(t *testing.T) { + now := sequencedNow(time.Unix(1_700_000_000, 0).UTC()) c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - _, _ = io.WriteString(w, "data: [DONE]\n\n") - }, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} }) + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Ack"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}, func(o *Options) { o.Now = now }) - attach := c.Action("gui.chat.attachImage").Run(context.Background(), core.NewOptions( - core.Option{Key: "filename", Value: "photo.png"}, - core.Option{Key: "mime_type", Value: "image/png"}, - core.Option{Key: "data", Value: "ZmFrZQ=="}, - core.Option{Key: "width", Value: 32}, - core.Option{Key: "height", Value: 32}, - )) - require.True(t, attach.OK) + require.True(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "First"}, + )).OK) + require.True(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Second"}, + )).OK) - send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( - core.Option{Key: "content", Value: "Describe this image"}, - )) - require.False(t, send.OK) - require.Error(t, send.Value.(error)) - assert.Contains(t, send.Value.(error).Error(), "does not support image input") + result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + require.True(t, result.OK) + conversations := result.Value.([]Conversation) + require.Len(t, conversations, 2) + assert.Equal(t, "Second", conversations[0].Messages[0].Content) + assert.Equal(t, "First", conversations[1].Messages[0].Content) +} + +func TestActionConversationsList_Bad_EmptyStoreReturnsEmptySlice(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + require.True(t, result.OK) + conversations, ok := result.Value.([]Conversation) + require.True(t, ok) + assert.Empty(t, conversations) +} + +func TestActionConversationsList_Ugly_IgnoresCorruptRows(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Ack"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}) + + require.True(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Good"}, + )).OK) + + svc := core.MustServiceFor[*Service](c, "chat") + require.NoError(t, svc.store.Set(conversationsGroup, "broken", "{")) + + result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + require.True(t, result.OK) + conversations := result.Value.([]Conversation) + require.Len(t, conversations, 1) + assert.Equal(t, "Good", conversations[0].Messages[0].Content) +} + +func TestActionConversationsLoad_Good_ReturnsConversation(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Reply"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}) + + require.True(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Hello"}, + )).OK) + conv := latestConversation(t, c) + + result := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: conv.ID}, + )) + require.True(t, result.OK) + loaded := result.Value.(Conversation) + require.Len(t, loaded.Messages, 2) + assert.Equal(t, "Reply", loaded.Messages[1].Content) +} + +func TestActionConversationsLoad_Bad_RequiresConversationID(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions()) + require.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "conversation id is required") +} + +func TestActionConversationsLoad_Ugly_UnknownConversationFails(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "missing"}, + )) + require.False(t, result.OK) + require.Error(t, result.Value.(error)) +} + +func TestActionConversationsDelete_Good_RemovesConversation(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Reply"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}) + + require.True(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Hello"}, + )).OK) + conv := latestConversation(t, c) + + deleted := c.Action("gui.chat.conversations.delete").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: conv.ID}, + )) + require.True(t, deleted.OK) + assert.Equal(t, true, deleted.Value) + + listed := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + require.True(t, listed.OK) + assert.Empty(t, listed.Value.([]Conversation)) +} + +func TestActionConversationsDelete_Bad_RequiresConversationID(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.conversations.delete").Run(context.Background(), core.NewOptions()) + require.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "conversation id is required") +} + +func TestActionConversationsDelete_Ugly_IsIdempotentForMissingConversation(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.conversations.delete").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "missing"}, + )) + require.True(t, result.OK) + assert.Equal(t, true, result.Value) +} + +func TestActionThinkingStart_Good_ReturnsActiveState(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + )) + require.True(t, result.OK) + state := result.Value.(ThinkingState) + assert.True(t, state.Active) + assert.False(t, state.StartedAt.IsZero()) +} + +func TestActionThinkingStart_Bad_RequiresConversationID(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions()) + require.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "conversation id is required") +} + +func TestActionThinkingStart_Ugly_RestartReplacesExistingState(t *testing.T) { + now := sequencedNow(time.Unix(1_700_000_000, 0).UTC()) + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}, func(o *Options) { o.Now = now }) + + first := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + )) + second := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + )) + require.True(t, first.OK) + require.True(t, second.OK) + assert.True(t, second.Value.(ThinkingState).StartedAt.After(first.Value.(ThinkingState).StartedAt)) +} + +func TestActionThinkingStop_Good_ClearsThinkingState(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + require.True(t, c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + core.Option{Key: "started_at", Value: time.Unix(1_700_000_000, 0).UTC()}, + )).OK) + + stopped := c.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + core.Option{Key: "duration_ms", Value: int64(25)}, + )) + require.True(t, stopped.OK) + state := stopped.Value.(ThinkingState) + assert.False(t, state.Active) + assert.Equal(t, int64(25), state.DurationMS) +} + +func TestActionThinkingStop_Bad_RequiresConversationID(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions()) + require.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "conversation id is required") +} + +func TestActionThinkingStop_Ugly_AllowsStopWithoutStart(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + )) + require.True(t, result.OK) + state := result.Value.(ThinkingState) + assert.False(t, state.Active) + assert.True(t, state.DurationMS >= 0) } diff --git a/pkg/display/display.go b/pkg/display/display.go index ac42148a..c5913ad5 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -676,8 +676,10 @@ func (s *Service) handleWSMessage(msg WSMessage) core.Result { return c.Action("gui.chat.settings.reset").Run(ctx, wsOptions(msg.Data)) case "chat:conversations:list": return c.Action("gui.chat.conversations.list").Run(ctx, wsOptions(msg.Data)) + case "chat:conversations:load": + return c.Action("gui.chat.conversations.load").Run(ctx, wsOptions(msg.Data)) case "chat:conversations:get": - return c.Action("gui.chat.conversations.get").Run(ctx, wsOptions(msg.Data)) + return c.Action("gui.chat.conversations.load").Run(ctx, wsOptions(msg.Data)) case "chat:conversations:delete": return c.Action("gui.chat.conversations.delete").Run(ctx, wsOptions(msg.Data)) case "chat:conversations:search": @@ -700,8 +702,10 @@ func (s *Service) handleWSMessage(msg WSMessage) core.Result { return c.Action("gui.chat.thinking.start").Run(ctx, wsOptions(msg.Data)) case "chat:thinking:append": return c.Action("gui.chat.thinking.append").Run(ctx, wsOptions(msg.Data)) + case "chat:thinking:stop": + return c.Action("gui.chat.thinking.stop").Run(ctx, wsOptions(msg.Data)) case "chat:thinking:end": - return c.Action("gui.chat.thinking.end").Run(ctx, wsOptions(msg.Data)) + return c.Action("gui.chat.thinking.stop").Run(ctx, wsOptions(msg.Data)) case "marketplace:list": return c.Action("display.marketplace.list").Run(ctx, wsOptions(msg.Data)) case "marketplace:fetch": diff --git a/pkg/mcp/tools_chat.go b/pkg/mcp/tools_chat.go index 0fe95728..13f557b7 100644 --- a/pkg/mcp/tools_chat.go +++ b/pkg/mcp/tools_chat.go @@ -1,4 +1,3 @@ -// pkg/mcp/tools_chat.go package mcp import ( @@ -24,14 +23,16 @@ type ChatMessage struct { FinishReason string `json:"finish_reason,omitempty"` } -type ChatModelEntry struct { +type ChatModel struct { Name string `json:"name"` - Architecture string `json:"architecture"` - QuantBits int `json:"quant_bits"` - SizeBytes int64 `json:"size_bytes"` - Loaded bool `json:"loaded"` - Backend string `json:"backend"` - SupportsVision bool `json:"supports_vision"` + Size int64 `json:"size"` + Status string `json:"status"` + Architecture string `json:"architecture,omitempty"` + QuantBits int `json:"quant_bits,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + Loaded bool `json:"loaded,omitempty"` + Backend string `json:"backend,omitempty"` + SupportsVision bool `json:"supports_vision,omitempty"` } type ChatSettings struct { @@ -54,15 +55,6 @@ type ChatConversation struct { Settings *ChatSettings `json:"settings,omitempty"` } -type ChatConversationSummary struct { - ID string `json:"id"` - Title string `json:"title"` - Model string `json:"model"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - MessageCount int `json:"message_count"` -} - type ChatThinkingState struct { Active bool `json:"active"` Content string `json:"content"` @@ -90,99 +82,68 @@ type ChatImageAttachment struct { Height int `json:"height"` } -// --- chat_send --- - type ChatSendInput struct { ConversationID string `json:"conversation_id,omitempty"` Content string `json:"content"` + Model string `json:"model,omitempty"` } type ChatSendOutput struct { - Conversation ChatConversation `json:"conversation"` + MessageID string `json:"message_id"` } func (s *Subsystem) chatSend(_ context.Context, _ *mcp.CallToolRequest, input ChatSendInput) (*mcp.CallToolResult, ChatSendOutput, error) { result := s.core.Action("gui.chat.send").Run(context.Background(), core.NewOptions( core.Option{Key: "conversation_id", Value: input.ConversationID}, core.Option{Key: "content", Value: input.Content}, + core.Option{Key: "model", Value: input.Model}, )) if !result.OK { if err, ok := result.Value.(error); ok { return nil, ChatSendOutput{}, err } - return nil, ChatSendOutput{}, nil + return nil, ChatSendOutput{}, coreerr.E("mcp.chatSend", "chat send failed", nil) } - conversation, err := decodeChatValue[ChatConversation](result.Value) - if err != nil { - return nil, ChatSendOutput{}, err + messageID, ok := result.Value.(string) + if !ok { + return nil, ChatSendOutput{}, coreerr.E("mcp.chatSend", "unexpected result type", nil) } - return nil, ChatSendOutput{Conversation: conversation}, nil + return nil, ChatSendOutput{MessageID: messageID}, nil } -// --- chat_clear --- - -type ChatClearInput struct { - ConversationID string `json:"conversation_id,omitempty"` - ID string `json:"id,omitempty"` -} - -type ChatClearOutput struct { - Conversation ChatConversation `json:"conversation"` -} - -func (s *Subsystem) chatClear(_ context.Context, _ *mcp.CallToolRequest, input ChatClearInput) (*mcp.CallToolResult, ChatClearOutput, error) { - result := s.core.Action("gui.chat.clear").Run(context.Background(), core.NewOptions( - core.Option{Key: "conversation_id", Value: input.ConversationID}, - core.Option{Key: "id", Value: input.ID}, - )) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatClearOutput{}, err - } - return nil, ChatClearOutput{}, nil - } - conversation, err := decodeChatValue[ChatConversation](result.Value) - if err != nil { - return nil, ChatClearOutput{}, err - } - return nil, ChatClearOutput{Conversation: conversation}, nil -} - -// --- chat_history --- - type ChatHistoryInput struct { ConversationID string `json:"conversation_id,omitempty"` ID string `json:"id,omitempty"` + Limit int `json:"limit,omitempty"` } type ChatHistoryOutput struct { - Conversation ChatConversation `json:"conversation"` + Messages []ChatMessage `json:"messages"` } func (s *Subsystem) chatHistory(_ context.Context, _ *mcp.CallToolRequest, input ChatHistoryInput) (*mcp.CallToolResult, ChatHistoryOutput, error) { result := s.core.Action("gui.chat.history").Run(context.Background(), core.NewOptions( core.Option{Key: "conversation_id", Value: input.ConversationID}, core.Option{Key: "id", Value: input.ID}, + core.Option{Key: "limit", Value: input.Limit}, )) if !result.OK { if err, ok := result.Value.(error); ok { return nil, ChatHistoryOutput{}, err } - return nil, ChatHistoryOutput{}, nil + return nil, ChatHistoryOutput{}, coreerr.E("mcp.chatHistory", "chat history failed", nil) } - conversation, err := decodeChatValue[ChatConversation](result.Value) + messages, err := decodeChatValue[[]ChatMessage](result.Value) if err != nil { return nil, ChatHistoryOutput{}, err } - return nil, ChatHistoryOutput{Conversation: conversation}, nil + return nil, ChatHistoryOutput{Messages: messages}, nil } -// --- chat_models --- - type ChatModelsInput struct{} type ChatModelsOutput struct { - Models []ChatModelEntry `json:"models"` + Models []ChatModel `json:"models"` } func (s *Subsystem) chatModels(_ context.Context, _ *mcp.CallToolRequest, _ ChatModelsInput) (*mcp.CallToolResult, ChatModelsOutput, error) { @@ -191,19 +152,18 @@ func (s *Subsystem) chatModels(_ context.Context, _ *mcp.CallToolRequest, _ Chat if err, ok := result.Value.(error); ok { return nil, ChatModelsOutput{}, err } - return nil, ChatModelsOutput{}, nil + return nil, ChatModelsOutput{}, coreerr.E("mcp.chatModels", "chat models failed", nil) } - models, err := decodeChatValue[[]ChatModelEntry](result.Value) + models, err := decodeChatValue[[]ChatModel](result.Value) if err != nil { return nil, ChatModelsOutput{}, err } return nil, ChatModelsOutput{Models: models}, nil } -// --- chat_select_model --- - type ChatSelectModelInput struct { - Model string `json:"model"` + Name string `json:"name,omitempty"` + Model string `json:"model,omitempty"` ConversationID string `json:"conversation_id,omitempty"` ID string `json:"id,omitempty"` } @@ -214,6 +174,7 @@ type ChatSelectModelOutput struct { func (s *Subsystem) chatSelectModel(_ context.Context, _ *mcp.CallToolRequest, input ChatSelectModelInput) (*mcp.CallToolResult, ChatSelectModelOutput, error) { result := s.core.Action("gui.chat.selectModel").Run(context.Background(), core.NewOptions( + core.Option{Key: "name", Value: input.Name}, core.Option{Key: "model", Value: input.Model}, core.Option{Key: "conversation_id", Value: input.ConversationID}, core.Option{Key: "id", Value: input.ID}, @@ -222,7 +183,7 @@ func (s *Subsystem) chatSelectModel(_ context.Context, _ *mcp.CallToolRequest, i if err, ok := result.Value.(error); ok { return nil, ChatSelectModelOutput{}, err } - return nil, ChatSelectModelOutput{}, nil + return nil, ChatSelectModelOutput{}, coreerr.E("mcp.chatSelectModel", "select model failed", nil) } settings, err := decodeChatValue[ChatSettings](result.Value) if err != nil { @@ -231,112 +192,10 @@ func (s *Subsystem) chatSelectModel(_ context.Context, _ *mcp.CallToolRequest, i return nil, ChatSelectModelOutput{Settings: settings}, nil } -// --- chat_settings_save --- - -type ChatSettingsSaveInput ChatSettings - -type ChatSettingsSaveOutput struct { - Settings ChatSettings `json:"settings"` -} - -func (s *Subsystem) chatSettingsSave(_ context.Context, _ *mcp.CallToolRequest, input ChatSettingsSaveInput) (*mcp.CallToolResult, ChatSettingsSaveOutput, error) { - result := s.core.Action("gui.chat.settings.save").Run(context.Background(), core.NewOptions( - core.Option{Key: "temperature", Value: input.Temperature}, - core.Option{Key: "top_p", Value: input.TopP}, - core.Option{Key: "top_k", Value: input.TopK}, - core.Option{Key: "max_tokens", Value: input.MaxTokens}, - core.Option{Key: "context_window", Value: input.ContextWindow}, - core.Option{Key: "system_prompt", Value: input.SystemPrompt}, - core.Option{Key: "default_model", Value: input.DefaultModel}, - )) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatSettingsSaveOutput{}, err - } - return nil, ChatSettingsSaveOutput{}, nil - } - settings, err := decodeChatValue[ChatSettings](result.Value) - if err != nil { - return nil, ChatSettingsSaveOutput{}, err - } - return nil, ChatSettingsSaveOutput{Settings: settings}, nil -} - -// --- chat_settings_load --- - -type ChatSettingsLoadInput struct{} - -type ChatSettingsLoadOutput struct { - Settings ChatSettings `json:"settings"` -} - -func (s *Subsystem) chatSettingsLoad(_ context.Context, _ *mcp.CallToolRequest, _ ChatSettingsLoadInput) (*mcp.CallToolResult, ChatSettingsLoadOutput, error) { - result := s.core.Action("gui.chat.settings.load").Run(context.Background(), core.NewOptions()) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatSettingsLoadOutput{}, err - } - return nil, ChatSettingsLoadOutput{}, nil - } - settings, err := decodeChatValue[ChatSettings](result.Value) - if err != nil { - return nil, ChatSettingsLoadOutput{}, err - } - return nil, ChatSettingsLoadOutput{Settings: settings}, nil -} - -// --- chat_settings_defaults --- - -type ChatSettingsDefaultsInput struct{} - -type ChatSettingsDefaultsOutput struct { - Settings ChatSettings `json:"settings"` -} - -func (s *Subsystem) chatSettingsDefaults(_ context.Context, _ *mcp.CallToolRequest, _ ChatSettingsDefaultsInput) (*mcp.CallToolResult, ChatSettingsDefaultsOutput, error) { - result := s.core.Action("gui.chat.settings.defaults").Run(context.Background(), core.NewOptions()) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatSettingsDefaultsOutput{}, err - } - return nil, ChatSettingsDefaultsOutput{}, nil - } - settings, err := decodeChatValue[ChatSettings](result.Value) - if err != nil { - return nil, ChatSettingsDefaultsOutput{}, err - } - return nil, ChatSettingsDefaultsOutput{Settings: settings}, nil -} - -// --- chat_settings_reset --- - -type ChatSettingsResetInput struct{} - -type ChatSettingsResetOutput struct { - Settings ChatSettings `json:"settings"` -} - -func (s *Subsystem) chatSettingsReset(_ context.Context, _ *mcp.CallToolRequest, _ ChatSettingsResetInput) (*mcp.CallToolResult, ChatSettingsResetOutput, error) { - result := s.core.Action("gui.chat.settings.reset").Run(context.Background(), core.NewOptions()) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatSettingsResetOutput{}, err - } - return nil, ChatSettingsResetOutput{}, nil - } - settings, err := decodeChatValue[ChatSettings](result.Value) - if err != nil { - return nil, ChatSettingsResetOutput{}, err - } - return nil, ChatSettingsResetOutput{Settings: settings}, nil -} - -// --- chat_conversations_list --- - type ChatConversationsListInput struct{} type ChatConversationsListOutput struct { - Conversations []ChatConversationSummary `json:"conversations"` + Conversations []ChatConversation `json:"conversations"` } func (s *Subsystem) chatConversationsList(_ context.Context, _ *mcp.CallToolRequest, _ ChatConversationsListInput) (*mcp.CallToolResult, ChatConversationsListOutput, error) { @@ -345,46 +204,42 @@ func (s *Subsystem) chatConversationsList(_ context.Context, _ *mcp.CallToolRequ if err, ok := result.Value.(error); ok { return nil, ChatConversationsListOutput{}, err } - return nil, ChatConversationsListOutput{}, nil + return nil, ChatConversationsListOutput{}, coreerr.E("mcp.chatConversationsList", "list conversations failed", nil) } - conversations, err := decodeChatValue[[]ChatConversationSummary](result.Value) + conversations, err := decodeChatValue[[]ChatConversation](result.Value) if err != nil { return nil, ChatConversationsListOutput{}, err } return nil, ChatConversationsListOutput{Conversations: conversations}, nil } -// --- chat_conversations_get --- - -type ChatConversationsGetInput struct { +type ChatConversationsLoadInput struct { ConversationID string `json:"conversation_id,omitempty"` ID string `json:"id,omitempty"` } -type ChatConversationsGetOutput struct { +type ChatConversationsLoadOutput struct { Conversation ChatConversation `json:"conversation"` } -func (s *Subsystem) chatConversationsGet(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsGetInput) (*mcp.CallToolResult, ChatConversationsGetOutput, error) { - result := s.core.Action("gui.chat.conversations.get").Run(context.Background(), core.NewOptions( +func (s *Subsystem) chatConversationsLoad(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsLoadInput) (*mcp.CallToolResult, ChatConversationsLoadOutput, error) { + result := s.core.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions( core.Option{Key: "conversation_id", Value: input.ConversationID}, core.Option{Key: "id", Value: input.ID}, )) if !result.OK { if err, ok := result.Value.(error); ok { - return nil, ChatConversationsGetOutput{}, err + return nil, ChatConversationsLoadOutput{}, err } - return nil, ChatConversationsGetOutput{}, nil + return nil, ChatConversationsLoadOutput{}, coreerr.E("mcp.chatConversationsLoad", "load conversation failed", nil) } conversation, err := decodeChatValue[ChatConversation](result.Value) if err != nil { - return nil, ChatConversationsGetOutput{}, err + return nil, ChatConversationsLoadOutput{}, err } - return nil, ChatConversationsGetOutput{Conversation: conversation}, nil + return nil, ChatConversationsLoadOutput{Conversation: conversation}, nil } -// --- chat_conversations_delete --- - type ChatConversationsDeleteInput struct { ConversationID string `json:"conversation_id,omitempty"` ID string `json:"id,omitempty"` @@ -403,218 +258,15 @@ func (s *Subsystem) chatConversationsDelete(_ context.Context, _ *mcp.CallToolRe if err, ok := result.Value.(error); ok { return nil, ChatConversationsDeleteOutput{}, err } - return nil, ChatConversationsDeleteOutput{}, nil + return nil, ChatConversationsDeleteOutput{}, coreerr.E("mcp.chatConversationsDelete", "delete conversation failed", nil) } - return nil, ChatConversationsDeleteOutput{Success: true}, nil -} - -// --- chat_conversations_search --- - -type ChatConversationsSearchInput struct { - Query string `json:"q"` -} - -type ChatConversationsSearchOutput struct { - Conversations []ChatConversationSummary `json:"conversations"` -} - -func (s *Subsystem) chatConversationsSearch(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsSearchInput) (*mcp.CallToolResult, ChatConversationsSearchOutput, error) { - result := s.core.Action("gui.chat.conversations.search").Run(context.Background(), core.NewOptions( - core.Option{Key: "q", Value: input.Query}, - )) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatConversationsSearchOutput{}, err - } - return nil, ChatConversationsSearchOutput{}, nil - } - conversations, err := decodeChatValue[[]ChatConversationSummary](result.Value) - if err != nil { - return nil, ChatConversationsSearchOutput{}, err - } - return nil, ChatConversationsSearchOutput{Conversations: conversations}, nil -} - -// --- chat_conversations_new --- - -type ChatConversationsNewInput struct{} - -type ChatConversationsNewOutput struct { - Conversation ChatConversation `json:"conversation"` -} - -func (s *Subsystem) chatConversationsNew(_ context.Context, _ *mcp.CallToolRequest, _ ChatConversationsNewInput) (*mcp.CallToolResult, ChatConversationsNewOutput, error) { - result := s.core.Action("gui.chat.conversations.new").Run(context.Background(), core.NewOptions()) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatConversationsNewOutput{}, err - } - return nil, ChatConversationsNewOutput{}, nil - } - conversation, err := decodeChatValue[ChatConversation](result.Value) - if err != nil { - return nil, ChatConversationsNewOutput{}, err - } - return nil, ChatConversationsNewOutput{Conversation: conversation}, nil -} - -// --- chat_conversation_save --- - -type ChatConversationSaveInput ChatConversation - -type ChatConversationSaveOutput struct { - Conversation ChatConversation `json:"conversation"` -} - -func (s *Subsystem) chatConversationSave(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationSaveInput) (*mcp.CallToolResult, ChatConversationSaveOutput, error) { - result := s.core.Action("gui.chat.conversation.save").Run(context.Background(), core.NewOptions( - core.Option{Key: "id", Value: input.ID}, - core.Option{Key: "title", Value: input.Title}, - core.Option{Key: "model", Value: input.Model}, - core.Option{Key: "created_at", Value: input.CreatedAt}, - core.Option{Key: "updated_at", Value: input.UpdatedAt}, - core.Option{Key: "messages", Value: input.Messages}, - core.Option{Key: "settings", Value: input.Settings}, - )) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatConversationSaveOutput{}, err - } - return nil, ChatConversationSaveOutput{}, nil - } - conversation, err := decodeChatValue[ChatConversation](result.Value) - if err != nil { - return nil, ChatConversationSaveOutput{}, err - } - return nil, ChatConversationSaveOutput{Conversation: conversation}, nil -} - -// --- chat_conversations_rename --- - -type ChatConversationsRenameInput struct { - ID string `json:"id"` - Title string `json:"title"` -} - -type ChatConversationsRenameOutput struct { - Conversation ChatConversation `json:"conversation"` -} - -func (s *Subsystem) chatConversationsRename(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsRenameInput) (*mcp.CallToolResult, ChatConversationsRenameOutput, error) { - result := s.core.Action("gui.chat.conversations.rename").Run(context.Background(), core.NewOptions( - core.Option{Key: "id", Value: input.ID}, - core.Option{Key: "title", Value: input.Title}, - )) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatConversationsRenameOutput{}, err - } - return nil, ChatConversationsRenameOutput{}, nil - } - conversation, err := decodeChatValue[ChatConversation](result.Value) - if err != nil { - return nil, ChatConversationsRenameOutput{}, err - } - return nil, ChatConversationsRenameOutput{Conversation: conversation}, nil -} - -// --- chat_conversations_export --- - -type ChatConversationsExportInput struct { - ConversationID string `json:"conversation_id,omitempty"` - ID string `json:"id,omitempty"` -} - -type ChatConversationsExportOutput struct { - Markdown string `json:"markdown"` -} - -func (s *Subsystem) chatConversationsExport(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsExportInput) (*mcp.CallToolResult, ChatConversationsExportOutput, error) { - result := s.core.Action("gui.chat.conversations.export").Run(context.Background(), core.NewOptions( - core.Option{Key: "conversation_id", Value: input.ConversationID}, - core.Option{Key: "id", Value: input.ID}, - )) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatConversationsExportOutput{}, err - } - return nil, ChatConversationsExportOutput{}, nil - } - markdown, ok := result.Value.(string) + success, ok := result.Value.(bool) if !ok { - return nil, ChatConversationsExportOutput{}, coreerr.E("mcp.chatConversationsExport", "unexpected result type", nil) + return nil, ChatConversationsDeleteOutput{}, coreerr.E("mcp.chatConversationsDelete", "unexpected result type", nil) } - return nil, ChatConversationsExportOutput{Markdown: markdown}, nil + return nil, ChatConversationsDeleteOutput{Success: success}, nil } -// --- chat_attach_image --- - -type ChatAttachImageInput struct { - ConversationID string `json:"conversation_id,omitempty"` - Filename string `json:"filename"` - MimeType string `json:"mime_type"` - Data string `json:"data"` - Width int `json:"width"` - Height int `json:"height"` -} - -type ChatAttachImageOutput struct { - Attachment ChatImageAttachment `json:"attachment"` -} - -func (s *Subsystem) chatAttachImage(_ context.Context, _ *mcp.CallToolRequest, input ChatAttachImageInput) (*mcp.CallToolResult, ChatAttachImageOutput, error) { - result := s.core.Action("gui.chat.attachImage").Run(context.Background(), core.NewOptions( - core.Option{Key: "conversation_id", Value: input.ConversationID}, - core.Option{Key: "filename", Value: input.Filename}, - core.Option{Key: "mime_type", Value: input.MimeType}, - core.Option{Key: "data", Value: input.Data}, - core.Option{Key: "width", Value: input.Width}, - core.Option{Key: "height", Value: input.Height}, - )) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatAttachImageOutput{}, err - } - return nil, ChatAttachImageOutput{}, nil - } - attachment, err := decodeChatValue[ChatImageAttachment](result.Value) - if err != nil { - return nil, ChatAttachImageOutput{}, err - } - return nil, ChatAttachImageOutput{Attachment: attachment}, nil -} - -// --- chat_remove_image --- - -type ChatRemoveImageInput struct { - ConversationID string `json:"conversation_id,omitempty"` - Index int `json:"index"` -} - -type ChatRemoveImageOutput struct { - Attachment ChatImageAttachment `json:"attachment"` -} - -func (s *Subsystem) chatRemoveImage(_ context.Context, _ *mcp.CallToolRequest, input ChatRemoveImageInput) (*mcp.CallToolResult, ChatRemoveImageOutput, error) { - result := s.core.Action("gui.chat.removeImage").Run(context.Background(), core.NewOptions( - core.Option{Key: "conversation_id", Value: input.ConversationID}, - core.Option{Key: "index", Value: input.Index}, - )) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatRemoveImageOutput{}, err - } - return nil, ChatRemoveImageOutput{}, nil - } - attachment, err := decodeChatValue[ChatImageAttachment](result.Value) - if err != nil { - return nil, ChatRemoveImageOutput{}, err - } - return nil, ChatRemoveImageOutput{Attachment: attachment}, nil -} - -// --- chat_thinking_start --- - type ChatThinkingStartInput struct { ConversationID string `json:"conversation_id"` MessageID string `json:"message_id,omitempty"` @@ -622,11 +274,7 @@ type ChatThinkingStartInput struct { } type ChatThinkingStartOutput struct { - ConversationID string `json:"conversation_id"` - MessageID string `json:"message_id,omitempty"` - Content string `json:"content,omitempty"` - StartedAt time.Time `json:"started_at"` - DurationMS int64 `json:"duration_ms,omitempty"` + State ChatThinkingState `json:"state"` } func (s *Subsystem) chatThinkingStart(_ context.Context, _ *mcp.CallToolRequest, input ChatThinkingStartInput) (*mcp.CallToolResult, ChatThinkingStartOutput, error) { @@ -639,124 +287,93 @@ func (s *Subsystem) chatThinkingStart(_ context.Context, _ *mcp.CallToolRequest, if err, ok := result.Value.(error); ok { return nil, ChatThinkingStartOutput{}, err } - return nil, ChatThinkingStartOutput{}, nil + return nil, ChatThinkingStartOutput{}, coreerr.E("mcp.chatThinkingStart", "thinking start failed", nil) } - output, err := decodeChatValue[ChatThinkingStartOutput](result.Value) + state, err := decodeChatValue[ChatThinkingState](result.Value) if err != nil { return nil, ChatThinkingStartOutput{}, err } - return nil, output, nil + return nil, ChatThinkingStartOutput{State: state}, nil } -// --- chat_thinking_append --- - -type ChatThinkingAppendInput struct { - ConversationID string `json:"conversation_id"` - MessageID string `json:"message_id,omitempty"` - Content string `json:"content"` -} - -type ChatThinkingAppendOutput struct { - Content string `json:"content"` -} - -func (s *Subsystem) chatThinkingAppend(_ context.Context, _ *mcp.CallToolRequest, input ChatThinkingAppendInput) (*mcp.CallToolResult, ChatThinkingAppendOutput, error) { - result := s.core.Action("gui.chat.thinking.append").Run(context.Background(), core.NewOptions( - core.Option{Key: "conversation_id", Value: input.ConversationID}, - core.Option{Key: "message_id", Value: input.MessageID}, - core.Option{Key: "content", Value: input.Content}, - )) - if !result.OK { - if err, ok := result.Value.(error); ok { - return nil, ChatThinkingAppendOutput{}, err - } - return nil, ChatThinkingAppendOutput{}, nil - } - content, ok := result.Value.(string) - if !ok { - return nil, ChatThinkingAppendOutput{}, coreerr.E("mcp.chatThinkingAppend", "unexpected result type", nil) - } - return nil, ChatThinkingAppendOutput{Content: content}, nil -} - -// --- chat_thinking_end --- - -type ChatThinkingEndInput struct { +type ChatThinkingStopInput struct { ConversationID string `json:"conversation_id"` MessageID string `json:"message_id,omitempty"` - DurationMS int64 `json:"duration_ms,omitempty"` StartedAt time.Time `json:"started_at,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` } -type ChatThinkingEndOutput struct { - DurationMS int64 `json:"duration_ms"` +type ChatThinkingStopOutput struct { + State ChatThinkingState `json:"state"` } -func (s *Subsystem) chatThinkingEnd(_ context.Context, _ *mcp.CallToolRequest, input ChatThinkingEndInput) (*mcp.CallToolResult, ChatThinkingEndOutput, error) { - result := s.core.Action("gui.chat.thinking.end").Run(context.Background(), core.NewOptions( +func (s *Subsystem) chatThinkingStop(_ context.Context, _ *mcp.CallToolRequest, input ChatThinkingStopInput) (*mcp.CallToolResult, ChatThinkingStopOutput, error) { + result := s.core.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions( core.Option{Key: "conversation_id", Value: input.ConversationID}, core.Option{Key: "message_id", Value: input.MessageID}, - core.Option{Key: "duration_ms", Value: input.DurationMS}, core.Option{Key: "started_at", Value: input.StartedAt}, + core.Option{Key: "duration_ms", Value: input.DurationMS}, )) if !result.OK { if err, ok := result.Value.(error); ok { - return nil, ChatThinkingEndOutput{}, err + return nil, ChatThinkingStopOutput{}, err } - return nil, ChatThinkingEndOutput{}, nil + return nil, ChatThinkingStopOutput{}, coreerr.E("mcp.chatThinkingStop", "thinking stop failed", nil) } - durationMS, ok := result.Value.(int64) - if !ok { - return nil, ChatThinkingEndOutput{}, coreerr.E("mcp.chatThinkingEnd", "unexpected result type", nil) + state, err := decodeChatValue[ChatThinkingState](result.Value) + if err != nil { + return nil, ChatThinkingStopOutput{}, err } - return nil, ChatThinkingEndOutput{DurationMS: durationMS}, nil + return nil, ChatThinkingStopOutput{State: state}, nil } func decodeChatValue[T any](value any) (T, error) { var output T result := core.JSONUnmarshalString(core.JSONMarshalString(value), &output) - if !result.OK { - if err, ok := result.Value.(error); ok { - return output, err - } - return output, coreerr.E("mcp.decodeChatValue", "failed to decode chat value", nil) + if result.OK { + return output, nil } - return output, nil + if err, ok := result.Value.(error); ok { + return output, err + } + return output, coreerr.E("mcp.decodeChatValue", "failed to decode chat value", nil) } func (s *Subsystem) registerChatTools(server *mcp.Server) { addTool(s, server, &mcp.Tool{ Name: "chat_send", - Description: `Send a chat message and stream a reply through the chat service. Example: {"conversation_id":"conv-1","content":"Hello"}`, + Description: `Send a chat message and return the streamed assistant message id. Example: {"conversation_id":"conv-1","content":"Hello","model":"lemma"}`, }, s.chatSend) - addTool(s, server, &mcp.Tool{ - Name: "chat_clear", - Description: `Clear a conversation's message history. Example: {"id":"conv-1"}`, - }, s.chatClear) addTool(s, server, &mcp.Tool{ Name: "chat_history", - Description: `Get a conversation and its message history. Example: {"id":"conv-1"}`, + Description: `Read chat message history for a conversation. Example: {"conversation_id":"conv-1","limit":20}`, }, s.chatHistory) - addTool(s, server, &mcp.Tool{Name: "chat_models", Description: "List available chat models"}, s.chatModels) + addTool(s, server, &mcp.Tool{ + Name: "chat_models", + Description: "List available chat models with size and status metadata", + }, s.chatModels) addTool(s, server, &mcp.Tool{ Name: "chat_select_model", - Description: `Select the active chat model for the current settings or conversation. Example: {"model":"lemer","conversation_id":"conv-1"}`, + Description: `Set the active chat model. Example: {"name":"lemma","conversation_id":"conv-1"}`, }, s.chatSelectModel) - addTool(s, server, &mcp.Tool{Name: "chat_settings_save", Description: "Persist chat settings"}, s.chatSettingsSave) - addTool(s, server, &mcp.Tool{Name: "chat_settings_load", Description: "Load persisted chat settings"}, s.chatSettingsLoad) - addTool(s, server, &mcp.Tool{Name: "chat_settings_defaults", Description: "Return the default chat settings"}, s.chatSettingsDefaults) - addTool(s, server, &mcp.Tool{Name: "chat_settings_reset", Description: "Reset chat settings to defaults"}, s.chatSettingsReset) - addTool(s, server, &mcp.Tool{Name: "chat_conversations_list", Description: "List stored conversations"}, s.chatConversationsList) - addTool(s, server, &mcp.Tool{Name: "chat_conversations_get", Description: "Get a stored conversation by id"}, s.chatConversationsGet) - addTool(s, server, &mcp.Tool{Name: "chat_conversations_delete", Description: "Delete a stored conversation"}, s.chatConversationsDelete) - addTool(s, server, &mcp.Tool{Name: "chat_conversations_search", Description: "Search stored conversations by text"}, s.chatConversationsSearch) - addTool(s, server, &mcp.Tool{Name: "chat_conversations_new", Description: "Create a new conversation"}, s.chatConversationsNew) - addTool(s, server, &mcp.Tool{Name: "chat_conversation_save", Description: "Save a conversation object"}, s.chatConversationSave) - addTool(s, server, &mcp.Tool{Name: "chat_conversations_rename", Description: "Rename a conversation"}, s.chatConversationsRename) - addTool(s, server, &mcp.Tool{Name: "chat_conversations_export", Description: "Export a conversation as Markdown"}, s.chatConversationsExport) - addTool(s, server, &mcp.Tool{Name: "chat_attach_image", Description: "Queue an image attachment for the next chat message"}, s.chatAttachImage) - addTool(s, server, &mcp.Tool{Name: "chat_remove_image", Description: "Remove a queued image attachment"}, s.chatRemoveImage) - addTool(s, server, &mcp.Tool{Name: "chat_thinking_start", Description: "Begin a thinking state for a conversation"}, s.chatThinkingStart) - addTool(s, server, &mcp.Tool{Name: "chat_thinking_append", Description: "Append thinking content for a conversation"}, s.chatThinkingAppend) - addTool(s, server, &mcp.Tool{Name: "chat_thinking_end", Description: "End a thinking state for a conversation"}, s.chatThinkingEnd) + addTool(s, server, &mcp.Tool{ + Name: "chat_conversations_list", + Description: "List stored chat conversations", + }, s.chatConversationsList) + addTool(s, server, &mcp.Tool{ + Name: "chat_conversations_load", + Description: `Load a stored chat conversation by id. Example: {"conversation_id":"conv-1"}`, + }, s.chatConversationsLoad) + addTool(s, server, &mcp.Tool{ + Name: "chat_conversations_delete", + Description: `Delete a stored chat conversation. Example: {"conversation_id":"conv-1"}`, + }, s.chatConversationsDelete) + addTool(s, server, &mcp.Tool{ + Name: "chat_thinking_start", + Description: `Mark a conversation as thinking. Example: {"conversation_id":"conv-1"}`, + }, s.chatThinkingStart) + addTool(s, server, &mcp.Tool{ + Name: "chat_thinking_stop", + Description: `Clear the thinking state for a conversation. Example: {"conversation_id":"conv-1"}`, + }, s.chatThinkingStop) }