feat(gui): pkg/chat + gui.chat.* IPC actions (MVP chat surface)
pkg/chat/service.go + messages.go implement the RFC §15.1–§15.8 chat service with the full IPC surface: - gui.chat.send — streams assistant reply, returns message id - gui.chat.history — []Message for a conversation - gui.chat.models — []Model (name, size, status) - gui.chat.selectModel — sets active model - gui.chat.conversations.list/load/delete - gui.chat.thinking.start/stop — explicit thinking-state tracking MCP tool registrations in pkg/mcp/tools_chat.go mirror the IPC surface (chat_send, chat_history, chat_models, etc). WS bridge in pkg/display/display.go wires chat:conversations:load and chat🤔stop, keeping legacy chat:conversations:get and chat🤔end paths pointed at the new handlers for compat. Good/Bad/Ugly tests per action in service_test.go + godoc example in service_example_test.go. go vet + go test ./pkg/chat/... + ./pkg/... all clean. Closes tasks.lthn.sh/view.php?id=14 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
dfff893f16
commit
7976b579a4
6 changed files with 867 additions and 662 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
61
pkg/chat/service_example_test.go
Normal file
61
pkg/chat/service_example_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue