feat(display): add chat and mode helpers
- pkg/display/chat.go: chat display utilities - pkg/display/mode.go: AppMode type - pkg/display/display.go: small additions Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
58308d74cc
commit
89fb765ef5
5 changed files with 1160 additions and 0 deletions
820
pkg/display/chat.go
Normal file
820
pkg/display/chat.go
Normal file
|
|
@ -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
|
||||
}
|
||||
154
pkg/display/chat_test.go
Normal file
154
pkg/display/chat_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
40
pkg/display/mode.go
Normal file
40
pkg/display/mode.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
136
pkg/display/routes.go
Normal file
136
pkg/display/routes.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue