diff --git a/pkg/display/chat.go b/pkg/display/chat.go new file mode 100644 index 00000000..5a511a2b --- /dev/null +++ b/pkg/display/chat.go @@ -0,0 +1,820 @@ +package display + +import ( + "sort" + "strings" + "sync" + "time" + + coreerr "forge.lthn.ai/core/go-log" + "forge.lthn.ai/core/config" + "forge.lthn.ai/core/go/pkg/core" +) + +const chatConfigSection = "chat" + +type ModelEntry 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,omitempty"` +} + +type ChatSettings struct { + Temperature float32 `json:"temperature"` + TopP float32 `json:"top_p"` + TopK int `json:"top_k"` + MaxTokens int `json:"max_tokens"` + ContextWindow int `json:"context_window"` + SystemPrompt string `json:"system_prompt"` + DefaultModel string `json:"default_model"` +} + +type ImageAttachment struct { + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + Data string `json:"data"` + Width int `json:"width"` + Height int `json:"height"` +} + +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +type ToolResult struct { + ToolCallID string `json:"tool_call_id"` + Content string `json:"content"` +} + +type ToolInvocation struct { + Call ToolCall `json:"call"` + Result ToolResult `json:"result"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + Error string `json:"error,omitempty"` +} + +type ThinkingState struct { + Active bool `json:"active"` + Content string `json:"content"` + StartedAt time.Time `json:"started_at,omitempty"` + FinishedAt time.Time `json:"finished_at,omitempty"` +} + +type ChatMessage struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + Attachments []ImageAttachment `json:"attachments,omitempty"` + Thinking *ThinkingState `json:"thinking,omitempty"` + ToolCalls []ToolInvocation `json:"tool_calls,omitempty"` +} + +type Conversation 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"` + Messages []ChatMessage `json:"messages"` + Settings *ChatSettings `json:"settings,omitempty"` +} + +type ChatSnapshot struct { + Settings ChatSettings `json:"settings"` + SelectedModel string `json:"selected_model"` + Conversations map[string]Conversation `json:"conversations"` + QueuedImages map[string][]ImageAttachment `json:"queued_images"` + Thinking map[string]ThinkingState `json:"thinking"` + StreamingMessage map[string]string `json:"streaming_message"` + Models []ModelEntry `json:"models"` +} + +type ChatStore struct { + mu sync.RWMutex + settings ChatSettings + selectedModel string + conversations map[string]Conversation + queuedImages map[string][]ImageAttachment + thinking map[string]ThinkingState + streamingMessage map[string]string + models []ModelEntry + nextID uint64 +} + +func defaultChatSettings() ChatSettings { + return ChatSettings{ + Temperature: 1.0, + TopP: 0.95, + TopK: 64, + MaxTokens: 2048, + ContextWindow: 8192, + SystemPrompt: "You are a helpful assistant.", + DefaultModel: "lemer", + } +} + +func defaultModelEntries() []ModelEntry { + return []ModelEntry{ + { + Name: "lemer", + Architecture: "gemma3", + QuantBits: 4, + SizeBytes: 1_500_000_000, + Loaded: true, + Backend: "metal", + SupportsVision: true, + }, + { + Name: "lemma", + Architecture: "gemma3", + QuantBits: 8, + SizeBytes: 3_200_000_000, + Loaded: false, + Backend: "metal", + SupportsVision: true, + }, + { + Name: "lemmy", + Architecture: "qwen3", + QuantBits: 4, + SizeBytes: 1_100_000_000, + Loaded: false, + Backend: "ollama", + }, + } +} + +func NewChatStore() *ChatStore { + settings := defaultChatSettings() + return &ChatStore{ + settings: settings, + selectedModel: settings.DefaultModel, + conversations: make(map[string]Conversation), + queuedImages: make(map[string][]ImageAttachment), + thinking: make(map[string]ThinkingState), + streamingMessage: make(map[string]string), + models: defaultModelEntries(), + } +} + +func (s *ChatStore) Load(cfg *config.Config) { + if cfg == nil { + return + } + + var snapshot ChatSnapshot + if err := cfg.Get(chatConfigSection, &snapshot); err != nil { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + if snapshot.Settings == (ChatSettings{}) { + snapshot.Settings = defaultChatSettings() + } + if snapshot.SelectedModel == "" { + snapshot.SelectedModel = snapshot.Settings.DefaultModel + } + if len(snapshot.Models) == 0 { + snapshot.Models = defaultModelEntries() + } + if snapshot.Conversations == nil { + snapshot.Conversations = make(map[string]Conversation) + } + if snapshot.QueuedImages == nil { + snapshot.QueuedImages = make(map[string][]ImageAttachment) + } + if snapshot.Thinking == nil { + snapshot.Thinking = make(map[string]ThinkingState) + } + if snapshot.StreamingMessage == nil { + snapshot.StreamingMessage = make(map[string]string) + } + + s.settings = snapshot.Settings + s.selectedModel = snapshot.SelectedModel + s.models = snapshot.Models + s.conversations = snapshot.Conversations + s.queuedImages = snapshot.QueuedImages + s.thinking = snapshot.Thinking + s.streamingMessage = snapshot.StreamingMessage + + for _, conv := range s.conversations { + if conv.ID == "" { + continue + } + if n := parseCounter(conv.ID); n > s.nextID { + s.nextID = n + } + for _, msg := range conv.Messages { + if n := parseCounter(msg.ID); n > s.nextID { + s.nextID = n + } + } + } +} + +func (s *ChatStore) Snapshot() ChatSnapshot { + s.mu.RLock() + defer s.mu.RUnlock() + return s.snapshotLocked() +} + +func (s *ChatStore) snapshotLocked() ChatSnapshot { + return ChatSnapshot{ + Settings: s.settings, + SelectedModel: s.selectedModel, + Conversations: cloneConversationMap(s.conversations), + QueuedImages: cloneAttachmentMap(s.queuedImages), + Thinking: cloneThinkingMap(s.thinking), + StreamingMessage: cloneStringMap(s.streamingMessage), + Models: append([]ModelEntry(nil), s.models...), + } +} + +func (s *ChatStore) persist(cfg *config.Config) error { + if cfg == nil { + return nil + } + if err := cfg.Set(chatConfigSection, s.Snapshot()); err != nil { + return err + } + return cfg.Commit() +} + +func (s *ChatStore) Models() []ModelEntry { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]ModelEntry(nil), s.models...) +} + +func (s *ChatStore) SelectModel(name string) ([]ModelEntry, error) { + s.mu.Lock() + defer s.mu.Unlock() + + index := -1 + for i := range s.models { + if s.models[i].Name == name { + index = i + break + } + } + if index == -1 { + return nil, coreerr.E("display.chat.SelectModel", "unknown model: "+name, nil) + } + + for i := range s.models { + s.models[i].Loaded = i == index + } + s.selectedModel = name + if s.settings.DefaultModel == "" { + s.settings.DefaultModel = name + } + return append([]ModelEntry(nil), s.models...), nil +} + +func (s *ChatStore) Settings() ChatSettings { + s.mu.RLock() + defer s.mu.RUnlock() + return s.settings +} + +func (s *ChatStore) SaveSettings(settings ChatSettings) ChatSettings { + s.mu.Lock() + defer s.mu.Unlock() + + if settings.DefaultModel == "" { + settings.DefaultModel = s.selectedModel + } + s.settings = settings + if s.selectedModel == "" { + s.selectedModel = settings.DefaultModel + } + return s.settings +} + +func (s *ChatStore) ResetSettings() ChatSettings { + return s.SaveSettings(defaultChatSettings()) +} + +func (s *ChatStore) NewConversation() Conversation { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UTC() + id := s.nextIdentifier("conv") + model := s.selectedModel + if model == "" { + model = s.settings.DefaultModel + } + conv := Conversation{ + ID: id, + Title: "New conversation", + Model: model, + CreatedAt: now, + UpdatedAt: now, + Messages: []ChatMessage{}, + } + s.conversations[id] = conv + return conv +} + +func (s *ChatStore) ListConversations() []Conversation { + s.mu.RLock() + defer s.mu.RUnlock() + + items := make([]Conversation, 0, len(s.conversations)) + for _, conv := range s.conversations { + items = append(items, cloneConversation(conv)) + } + sort.Slice(items, func(i, j int) bool { + return items[i].UpdatedAt.After(items[j].UpdatedAt) + }) + return items +} + +func (s *ChatStore) Conversation(id string) (Conversation, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + conv, ok := s.conversations[id] + if !ok { + return Conversation{}, false + } + return cloneConversation(conv), true +} + +func (s *ChatStore) DeleteConversation(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.conversations[id]; !ok { + return false + } + delete(s.conversations, id) + delete(s.queuedImages, id) + delete(s.thinking, id) + delete(s.streamingMessage, id) + return true +} + +func (s *ChatStore) SearchConversations(query string) []Conversation { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return s.ListConversations() + } + + s.mu.RLock() + defer s.mu.RUnlock() + + var items []Conversation + for _, conv := range s.conversations { + if strings.Contains(strings.ToLower(conv.Title), query) { + items = append(items, cloneConversation(conv)) + continue + } + for _, msg := range conv.Messages { + if strings.Contains(strings.ToLower(msg.Content), query) { + items = append(items, cloneConversation(conv)) + break + } + } + } + + sort.Slice(items, func(i, j int) bool { + return items[i].UpdatedAt.After(items[j].UpdatedAt) + }) + return items +} + +func (s *ChatStore) History(conversationID string) ([]ChatMessage, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + conv, ok := s.conversations[conversationID] + if !ok { + return nil, coreerr.E("display.chat.History", "conversation not found: "+conversationID, nil) + } + return append([]ChatMessage(nil), conv.Messages...), nil +} + +func (s *ChatStore) ClearConversation(conversationID string) (Conversation, error) { + s.mu.Lock() + defer s.mu.Unlock() + + conv, ok := s.conversations[conversationID] + if !ok { + return Conversation{}, coreerr.E("display.chat.ClearConversation", "conversation not found: "+conversationID, nil) + } + conv.Messages = nil + conv.Title = "New conversation" + conv.UpdatedAt = time.Now().UTC() + s.conversations[conversationID] = conv + delete(s.queuedImages, conversationID) + delete(s.thinking, conversationID) + delete(s.streamingMessage, conversationID) + return cloneConversation(conv), nil +} + +func (s *ChatStore) QueueImage(conversationID string, attachment ImageAttachment) ([]ImageAttachment, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.conversations[conversationID]; !ok { + return nil, coreerr.E("display.chat.QueueImage", "conversation not found: "+conversationID, nil) + } + s.queuedImages[conversationID] = append(s.queuedImages[conversationID], attachment) + return append([]ImageAttachment(nil), s.queuedImages[conversationID]...), nil +} + +func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, ChatMessage, ChatMessage, error) { + s.mu.Lock() + defer s.mu.Unlock() + + conv, ok := s.conversations[conversationID] + if !ok { + return Conversation{}, ChatMessage{}, ChatMessage{}, coreerr.E("display.chat.SendMessage", "conversation not found: "+conversationID, nil) + } + if strings.TrimSpace(content) == "" { + return Conversation{}, ChatMessage{}, ChatMessage{}, coreerr.E("display.chat.SendMessage", "message content is required", nil) + } + + now := time.Now().UTC() + userMessage := ChatMessage{ + ID: s.nextIdentifier("msg"), + Role: "user", + Content: content, + CreatedAt: now, + Attachments: append([]ImageAttachment(nil), s.queuedImages[conversationID]...), + } + assistantMessage := ChatMessage{ + ID: s.nextIdentifier("msg"), + Role: "assistant", + Content: buildAssistantPlaceholder(conv.Model, content), + CreatedAt: now.Add(250 * time.Millisecond), + } + if thinking, ok := s.thinking[conversationID]; ok && strings.TrimSpace(thinking.Content) != "" { + copyThinking := thinking + copyThinking.Active = false + copyThinking.FinishedAt = time.Now().UTC() + assistantMessage.Thinking = ©Thinking + s.thinking[conversationID] = copyThinking + } + + conv.Messages = append(conv.Messages, userMessage, assistantMessage) + if len(conv.Messages) == 2 { + conv.Title = deriveConversationTitle(content) + } + conv.UpdatedAt = assistantMessage.CreatedAt + if conv.Model == "" { + conv.Model = s.selectedModel + } + s.conversations[conversationID] = conv + delete(s.queuedImages, conversationID) + delete(s.streamingMessage, conversationID) + + return cloneConversation(conv), userMessage, assistantMessage, nil +} + +func (s *ChatStore) StartThinking(conversationID string) (ThinkingState, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.conversations[conversationID]; !ok { + return ThinkingState{}, coreerr.E("display.chat.StartThinking", "conversation not found: "+conversationID, nil) + } + state := ThinkingState{ + Active: true, + StartedAt: time.Now().UTC(), + } + s.thinking[conversationID] = state + return state, nil +} + +func (s *ChatStore) AppendThinking(conversationID, content string) (ThinkingState, error) { + s.mu.Lock() + defer s.mu.Unlock() + + state, ok := s.thinking[conversationID] + if !ok { + state = ThinkingState{Active: true, StartedAt: time.Now().UTC()} + } + state.Active = true + state.Content += content + s.thinking[conversationID] = state + return state, nil +} + +func (s *ChatStore) EndThinking(conversationID string) (ThinkingState, error) { + s.mu.Lock() + defer s.mu.Unlock() + + state, ok := s.thinking[conversationID] + if !ok { + return ThinkingState{}, coreerr.E("display.chat.EndThinking", "thinking state not found: "+conversationID, nil) + } + state.Active = false + state.FinishedAt = time.Now().UTC() + s.thinking[conversationID] = state + return state, nil +} + +func (s *ChatStore) RecordToolResult(conversationID string, call ToolCall, result ToolResult, errText string) (Conversation, error) { + s.mu.Lock() + defer s.mu.Unlock() + + conv, ok := s.conversations[conversationID] + if !ok { + return Conversation{}, coreerr.E("display.chat.RecordToolResult", "conversation not found: "+conversationID, nil) + } + if len(conv.Messages) == 0 { + return Conversation{}, coreerr.E("display.chat.RecordToolResult", "conversation has no messages", nil) + } + last := &conv.Messages[len(conv.Messages)-1] + last.ToolCalls = append(last.ToolCalls, ToolInvocation{ + Call: call, + Result: result, + StartedAt: time.Now().UTC(), + EndedAt: time.Now().UTC(), + Error: errText, + }) + conv.UpdatedAt = time.Now().UTC() + s.conversations[conversationID] = conv + return cloneConversation(conv), nil +} + +type QueryChatHistory struct { + ConversationID string `json:"conversation_id"` +} + +type TaskChatSend struct { + ConversationID string `json:"conversation_id"` + Content string `json:"content"` +} + +type TaskChatClear struct { + ConversationID string `json:"conversation_id"` +} + +type QueryChatModels struct{} + +type TaskSelectModel struct { + Model string `json:"model"` +} + +type TaskChatSettingsSave struct { + Settings ChatSettings `json:"settings"` +} + +type QueryChatSettingsLoad struct{} + +type TaskChatSettingsReset struct{} + +type QueryConversationsList struct{} + +type QueryConversationGet struct { + ID string `json:"id"` +} + +type QueryConversationsSearch struct { + Query string `json:"q"` +} + +type TaskConversationDelete struct { + ID string `json:"id"` +} + +type TaskConversationNew struct{} + +type TaskAttachImage struct { + ConversationID string `json:"conversation_id"` + Attachment ImageAttachment `json:"attachment"` +} + +type TaskThinkingStart struct { + ConversationID string `json:"conversation_id"` +} + +type TaskThinkingAppend struct { + ConversationID string `json:"conversation_id"` + Content string `json:"content"` +} + +type TaskThinkingEnd struct { + ConversationID string `json:"conversation_id"` +} + +type TaskRecordToolCall struct { + ConversationID string `json:"conversation_id"` + Call ToolCall `json:"call"` + Result ToolResult `json:"result"` + Error string `json:"error,omitempty"` +} + +func (s *Service) handleChatQuery(_ *core.Core, q core.Query) (any, bool, error) { + switch q := q.(type) { + case QueryChatHistory: + history, err := s.chat.History(q.ConversationID) + return history, true, err + case QueryChatModels: + return s.chat.Models(), true, nil + case QueryChatSettingsLoad: + return s.chat.Settings(), true, nil + case QueryConversationsList: + return s.chat.ListConversations(), true, nil + case QueryConversationGet: + conv, ok := s.chat.Conversation(q.ID) + if !ok { + return nil, true, coreerr.E("display.chat.QueryConversationGet", "conversation not found: "+q.ID, nil) + } + return conv, true, nil + case QueryConversationsSearch: + return s.chat.SearchConversations(q.Query), true, nil + default: + return nil, false, nil + } +} + +func (s *Service) handleChatTask(_ *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskConversationNew: + conv := s.chat.NewConversation() + return conv, true, s.chat.persist(s.configFile) + case TaskChatSend: + conv, userMessage, assistantMessage, err := s.chat.SendMessage(t.ConversationID, t.Content) + if err != nil { + return nil, true, err + } + if s.events != nil { + s.events.Emit(Event{ + Type: "chat.message", + Data: map[string]any{ + "conversation_id": t.ConversationID, + "user": userMessage, + "assistant": assistantMessage, + }, + }) + } + return conv, true, s.chat.persist(s.configFile) + case TaskChatClear: + conv, err := s.chat.ClearConversation(t.ConversationID) + if err != nil { + return nil, true, err + } + return conv, true, s.chat.persist(s.configFile) + case TaskSelectModel: + models, err := s.chat.SelectModel(t.Model) + if err != nil { + return nil, true, err + } + return models, true, s.chat.persist(s.configFile) + case TaskChatSettingsSave: + settings := s.chat.SaveSettings(t.Settings) + return settings, true, s.chat.persist(s.configFile) + case TaskChatSettingsReset: + settings := s.chat.ResetSettings() + return settings, true, s.chat.persist(s.configFile) + case TaskConversationDelete: + deleted := s.chat.DeleteConversation(t.ID) + if !deleted { + return nil, true, coreerr.E("display.chat.TaskConversationDelete", "conversation not found: "+t.ID, nil) + } + return deleted, true, s.chat.persist(s.configFile) + case TaskAttachImage: + attachments, err := s.chat.QueueImage(t.ConversationID, t.Attachment) + if err != nil { + return nil, true, err + } + return attachments, true, s.chat.persist(s.configFile) + case TaskThinkingStart: + state, err := s.chat.StartThinking(t.ConversationID) + if err != nil { + return nil, true, err + } + return state, true, s.chat.persist(s.configFile) + case TaskThinkingAppend: + state, err := s.chat.AppendThinking(t.ConversationID, t.Content) + if err != nil { + return nil, true, err + } + return state, true, s.chat.persist(s.configFile) + case TaskThinkingEnd: + state, err := s.chat.EndThinking(t.ConversationID) + if err != nil { + return nil, true, err + } + return state, true, s.chat.persist(s.configFile) + case TaskRecordToolCall: + conv, err := s.chat.RecordToolResult(t.ConversationID, t.Call, t.Result, t.Error) + if err != nil { + return nil, true, err + } + return conv, true, s.chat.persist(s.configFile) + default: + return nil, false, nil + } +} + +func deriveConversationTitle(content string) string { + content = strings.TrimSpace(content) + if content == "" { + return "New conversation" + } + runes := []rune(content) + if len(runes) <= 50 { + return content + } + return strings.TrimSpace(string(runes[:50])) + "..." +} + +func buildAssistantPlaceholder(model, prompt string) string { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return "Waiting for the local inference pipeline." + } + if model == "" { + model = "local model" + } + return "Local inference is not wired in this workspace yet. " + + "Captured your prompt for " + model + " and stored it in chat history." +} + +func parseCounter(value string) uint64 { + if idx := strings.LastIndex(value, "-"); idx >= 0 && idx < len(value)-1 { + value = value[idx+1:] + } + var counter uint64 + for _, ch := range value { + if ch < '0' || ch > '9' { + return 0 + } + counter = counter*10 + uint64(ch-'0') + } + return counter +} + +func (s *ChatStore) nextIdentifier(prefix string) string { + s.nextID++ + return prefix + "-" + strconvFormatUint(s.nextID) +} + +func strconvFormatUint(v uint64) string { + if v == 0 { + return "0" + } + var digits [20]byte + i := len(digits) + for v > 0 { + i-- + digits[i] = byte('0' + v%10) + v /= 10 + } + return string(digits[i:]) +} + +func cloneConversationMap(src map[string]Conversation) map[string]Conversation { + dst := make(map[string]Conversation, len(src)) + for key, value := range src { + dst[key] = cloneConversation(value) + } + return dst +} + +func cloneConversation(conv Conversation) Conversation { + clone := conv + clone.Messages = append([]ChatMessage(nil), conv.Messages...) + return clone +} + +func cloneAttachmentMap(src map[string][]ImageAttachment) map[string][]ImageAttachment { + dst := make(map[string][]ImageAttachment, len(src)) + for key, value := range src { + dst[key] = append([]ImageAttachment(nil), value...) + } + return dst +} + +func cloneThinkingMap(src map[string]ThinkingState) map[string]ThinkingState { + dst := make(map[string]ThinkingState, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} + +func cloneStringMap(src map[string]string) map[string]string { + dst := make(map[string]string, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} diff --git a/pkg/display/chat_test.go b/pkg/display/chat_test.go new file mode 100644 index 00000000..93bd3056 --- /dev/null +++ b/pkg/display/chat_test.go @@ -0,0 +1,154 @@ +package display + +import ( + "context" + "path/filepath" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectMode_Good(t *testing.T) { + mode := DetectMode([]string{"core-gui", "--mode=worker"}, func(string) string { return "" }) + assert.Equal(t, ModeWorker, mode) + + mode = DetectMode(nil, func(string) string { return "manager" }) + assert.Equal(t, ModeManager, mode) + + mode = DetectMode(nil, func(string) string { return "bogus" }) + assert.Equal(t, ModeManager, mode) +} + +func TestChatLifecycle_Good(t *testing.T) { + svc, c := newTestDisplayService(t) + + convResult, handled, err := c.PERFORM(TaskConversationNew{}) + require.NoError(t, err) + require.True(t, handled) + conv := convResult.(Conversation) + require.NotEmpty(t, conv.ID) + + _, handled, err = c.PERFORM(TaskAttachImage{ + ConversationID: conv.ID, + Attachment: ImageAttachment{ + Filename: "diagram.png", + MimeType: "image/png", + Data: "ZmFrZQ==", + Width: 640, + Height: 480, + }, + }) + require.NoError(t, err) + require.True(t, handled) + + _, handled, err = c.PERFORM(TaskThinkingStart{ConversationID: conv.ID}) + require.NoError(t, err) + require.True(t, handled) + + _, handled, err = c.PERFORM(TaskThinkingAppend{ + ConversationID: conv.ID, + Content: "Consider the local context first.", + }) + require.NoError(t, err) + require.True(t, handled) + + sendResult, handled, err := c.PERFORM(TaskChatSend{ + ConversationID: conv.ID, + Content: "Explain local inference.", + }) + require.NoError(t, err) + require.True(t, handled) + + updated := sendResult.(Conversation) + require.Len(t, updated.Messages, 2) + assert.Equal(t, "user", updated.Messages[0].Role) + assert.Equal(t, "assistant", updated.Messages[1].Role) + assert.Len(t, updated.Messages[0].Attachments, 1) + if assert.NotNil(t, updated.Messages[1].Thinking) { + assert.Contains(t, updated.Messages[1].Thinking.Content, "local context") + } + + historyResult, handled, err := c.QUERY(QueryChatHistory{ConversationID: conv.ID}) + require.NoError(t, err) + require.True(t, handled) + history := historyResult.([]ChatMessage) + require.Len(t, history, 2) + + searchResult, handled, err := c.QUERY(QueryConversationsSearch{Query: "inference"}) + require.NoError(t, err) + require.True(t, handled) + require.Len(t, searchResult.([]Conversation), 1) + + settingsResult, handled, err := c.PERFORM(TaskChatSettingsSave{ + Settings: ChatSettings{ + Temperature: 0.7, + TopP: 0.9, + TopK: 40, + MaxTokens: 1024, + ContextWindow: 4096, + SystemPrompt: "Be concise.", + DefaultModel: "lemma", + }, + }) + require.NoError(t, err) + require.True(t, handled) + assert.Equal(t, float32(0.7), settingsResult.(ChatSettings).Temperature) + + modelsResult, handled, err := c.PERFORM(TaskSelectModel{Model: "lemma"}) + require.NoError(t, err) + require.True(t, handled) + models := modelsResult.([]ModelEntry) + assert.True(t, models[1].Loaded) + + require.NoError(t, svc.chat.persist(svc.configFile)) +} + +func TestChatPersistence_Good(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui.yaml") + + svc, err := NewService() + require.NoError(t, err) + svc.loadConfigFrom(path) + + conv := svc.chat.NewConversation() + _, _, _, err = svc.chat.SendMessage(conv.ID, "Persist this conversation.") + require.NoError(t, err) + require.NoError(t, svc.chat.persist(svc.configFile)) + + reloaded, err := NewService() + require.NoError(t, err) + reloaded.loadConfigFrom(path) + + restored, ok := reloaded.chat.Conversation(conv.ID) + require.True(t, ok) + require.Len(t, restored.Messages, 2) + assert.Equal(t, "Persist this conversation.", restored.Messages[0].Content) +} + +func TestResolveScheme_Good(t *testing.T) { + c, err := core.New( + core.WithService(Register(nil)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + svc := core.MustServiceFor[*Service](c, "display") + conv := svc.chat.NewConversation() + _, _, _, err = svc.chat.SendMessage(conv.ID, "Searchable store entry.") + require.NoError(t, err) + + storeResponse, err := svc.ResolveScheme(context.Background(), "core://store?q=searchable") + require.NoError(t, err) + assert.Equal(t, "store", storeResponse.Path) + results := storeResponse.Data["results"].([]StoreSearchResult) + require.Len(t, results, 2) + + settingsResponse, err := svc.ResolveScheme(context.Background(), "core://settings") + require.NoError(t, err) + assert.Equal(t, "settings", settingsResponse.Path) + assert.Contains(t, settingsResponse.Data, "settings") + assert.Contains(t, settingsResponse.Data, "models") +} diff --git a/pkg/display/display.go b/pkg/display/display.go index eac9ba2d..9c9febe6 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -42,6 +42,8 @@ type Service struct { configData map[string]map[string]any configFile *config.Config // config instance for file persistence events *WSEventManager + chat *ChatStore + schemes map[string]SchemeHandler } // NewService returns a display Service with empty config sections. @@ -53,6 +55,8 @@ func NewService() (*Service, error) { "systray": {}, "menu": {}, }, + chat: NewChatStore(), + schemes: make(map[string]SchemeHandler), }, nil } @@ -84,6 +88,10 @@ func (s *Service) OnStartup(ctx context.Context) error { // Register config query/task handlers — available NOW for sub-services s.Core().RegisterQuery(s.handleConfigQuery) s.Core().RegisterTask(s.handleConfigTask) + s.Core().RegisterQuery(s.handleChatQuery) + s.Core().RegisterTask(s.handleChatTask) + + s.registerBuiltinSchemes() // Initialise Wails wrappers if app is available (nil in tests) if s.wailsApp != nil { @@ -1200,6 +1208,8 @@ func (s *Service) loadConfigFrom(path string) { s.configData[section] = data } } + + s.chat.Load(configFile) } func (s *Service) handleConfigQuery(c *core.Core, q core.Query) (any, bool, error) { diff --git a/pkg/display/mode.go b/pkg/display/mode.go new file mode 100644 index 00000000..58f19b8c --- /dev/null +++ b/pkg/display/mode.go @@ -0,0 +1,40 @@ +package display + +import "strings" + +type AppMode string + +const ( + ModeManager AppMode = "manager" + ModeWorker AppMode = "worker" +) + +func DetectMode(args []string, getenv func(string) string) AppMode { + if getenv != nil { + if mode, ok := parseMode(getenv("CORE_GUI_MODE")); ok { + return mode + } + } + + for _, arg := range args { + if !strings.HasPrefix(arg, "--mode=") { + continue + } + if mode, ok := parseMode(strings.TrimPrefix(arg, "--mode=")); ok { + return mode + } + } + + return ModeManager +} + +func parseMode(raw string) (AppMode, bool) { + switch strings.TrimSpace(strings.ToLower(raw)) { + case string(ModeManager): + return ModeManager, true + case string(ModeWorker): + return ModeWorker, true + default: + return "", false + } +} diff --git a/pkg/display/routes.go b/pkg/display/routes.go new file mode 100644 index 00000000..d9702131 --- /dev/null +++ b/pkg/display/routes.go @@ -0,0 +1,136 @@ +package display + +import ( + "context" + "encoding/json" + "net/url" + "sort" + "strings" + "time" + + coreerr "forge.lthn.ai/core/go-log" +) + +type SchemeResponse struct { + Scheme string `json:"scheme"` + Path string `json:"path"` + ContentType string `json:"content_type"` + StatusCode int `json:"status_code"` + Data map[string]any `json:"data"` +} + +type SchemeHandler func(ctx context.Context, path string, params url.Values) (SchemeResponse, error) + +type StoreSearchResult struct { + Origin string `json:"origin"` + ConversationID string `json:"conversation_id"` + Title string `json:"title"` + Role string `json:"role"` + Snippet string `json:"snippet"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *Service) registerBuiltinSchemes() { + s.HandleScheme("core", s.handleCoreScheme) +} + +func (s *Service) HandleScheme(scheme string, handler SchemeHandler) { + if scheme == "" || handler == nil { + return + } + s.schemes[strings.ToLower(scheme)] = handler +} + +func (s *Service) ResolveScheme(ctx context.Context, rawURL string) (SchemeResponse, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return SchemeResponse{}, err + } + handler, ok := s.schemes[strings.ToLower(parsed.Scheme)] + if !ok { + return SchemeResponse{}, coreerr.E("display.ResolveScheme", "scheme not registered: "+parsed.Scheme, nil) + } + + path := parsed.Host + if parsed.Path != "" { + if path == "" { + path = strings.TrimPrefix(parsed.Path, "/") + } else { + path += parsed.Path + } + } + + return handler(ctx, strings.Trim(path, "/"), parsed.Query()) +} + +func (s *Service) handleCoreScheme(_ context.Context, path string, params url.Values) (SchemeResponse, error) { + switch path { + case "settings": + settings := s.chat.Settings() + models := s.chat.Models() + return SchemeResponse{ + Scheme: "core", + Path: "settings", + ContentType: "application/json", + StatusCode: 200, + Data: map[string]any{ + "settings": settings, + "models": models, + }, + }, nil + case "store": + query := strings.TrimSpace(params.Get("q")) + results := s.searchStore(query) + return SchemeResponse{ + Scheme: "core", + Path: "store", + ContentType: "application/json", + StatusCode: 200, + Data: map[string]any{ + "query": query, + "results": results, + }, + }, nil + default: + return SchemeResponse{}, coreerr.E("display.handleCoreScheme", "unknown core route: "+path, nil) + } +} + +func (s *Service) searchStore(query string) []StoreSearchResult { + query = strings.ToLower(strings.TrimSpace(query)) + var results []StoreSearchResult + + for _, conv := range s.chat.ListConversations() { + for _, msg := range conv.Messages { + if query != "" && !strings.Contains(strings.ToLower(msg.Content), query) && !strings.Contains(strings.ToLower(conv.Title), query) { + continue + } + results = append(results, StoreSearchResult{ + Origin: "chat://conversations", + ConversationID: conv.ID, + Title: conv.Title, + Role: msg.Role, + Snippet: trimSnippet(msg.Content), + UpdatedAt: conv.UpdatedAt, + }) + } + } + + sort.Slice(results, func(i, j int) bool { + return results[i].UpdatedAt.After(results[j].UpdatedAt) + }) + return results +} + +func trimSnippet(content string) string { + content = strings.TrimSpace(content) + runes := []rune(content) + if len(runes) <= 120 { + return content + } + return string(runes[:120]) + "..." +} + +func (r SchemeResponse) JSON() ([]byte, error) { + return json.Marshal(r) +}