gui/pkg/chat/service.go

1174 lines
34 KiB
Go
Raw Normal View History

package chat
import (
"bytes"
"context"
"io"
"net/http"
"os"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
2026-04-15 18:46:14 +01:00
"dappco.re/go/store"
guimcp "forge.lthn.ai/core/gui/pkg/mcp"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"gopkg.in/yaml.v3"
)
const (
conversationsGroup = "chat_conversations"
settingsGroup = "chat_settings"
settingsKey = "global"
)
type Options struct {
APIURL string
StorePath string
HTTPClient *http.Client
ModelRoots []string
ToolExecutor ToolExecutor
Now func() time.Time
}
type Service struct {
*core.ServiceRuntime[Options]
options Options
2026-04-15 18:46:14 +01:00
store *store.Store
httpClient *http.Client
toolExecutor ToolExecutor
toolHandler *ToolCallHandler
pendingAttachments map[string][]ImageAttachment
mu sync.Mutex
}
type sendInput struct {
ConversationID string `json:"conversation_id,omitempty"`
Content string `json:"content"`
}
type conversationInput struct {
ID string `json:"id,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
}
type searchInput struct {
Query string `json:"q"`
}
type renameInput struct {
ID string `json:"id"`
Title string `json:"title"`
}
type thinkingInput struct {
ConversationID string `json:"conversation_id,omitempty"`
MessageID string `json:"message_id,omitempty"`
Content string `json:"content,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
DurationMS int64 `json:"duration_ms,omitempty"`
}
type selectModelInput struct {
Model string `json:"model"`
ConversationID string `json:"conversation_id,omitempty"`
ID string `json:"id,omitempty"`
}
type attachImageInput struct {
ConversationID string `json:"conversation_id,omitempty"`
ImageAttachment `json:",inline"`
}
type removeImageInput struct {
ConversationID string `json:"conversation_id,omitempty"`
Index int `json:"index"`
}
type openAIRequest struct {
Model string `json:"model"`
Messages []openAIMessage `json:"messages"`
Temperature float32 `json:"temperature,omitempty"`
TopP float32 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream"`
Tools []openAIToolSpec `json:"tools,omitempty"`
ToolChoice string `json:"tool_choice,omitempty"`
}
type openAIMessage struct {
Role string `json:"role"`
Content any `json:"content,omitempty"`
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type openAIToolSpec struct {
Type string `json:"type"`
Function openAIFunctionSpec `json:"function"`
}
type openAIFunctionSpec struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
}
type openAIToolCall struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Function openAIFunctionCall `json:"function"`
}
type openAIFunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type configuredModels struct {
Default string `yaml:"default"`
DefaultModel string `yaml:"default_model"`
Models []struct {
Name string `yaml:"name"`
Path string `yaml:"path"`
Architecture string `yaml:"architecture"`
Backend string `yaml:"backend"`
} `yaml:"models"`
}
func Register(optionFns ...func(*Options)) func(*core.Core) core.Result {
options := Options{
APIURL: "http://localhost:8090",
StorePath: filepath.Join(core.Env("DIR_HOME"), ".core", "gui", "chat.db"),
HTTPClient: &http.Client{Timeout: 5 * time.Minute},
ModelRoots: defaultModelRoots(),
Now: time.Now,
}
for _, fn := range optionFns {
fn(&options)
}
return func(c *core.Core) core.Result {
svc := &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, options),
options: options,
httpClient: options.HTTPClient,
pendingAttachments: make(map[string][]ImageAttachment),
}
return core.Result{Value: svc, OK: true}
}
}
func (s *Service) OnStartup(_ context.Context) core.Result {
if err := os.MkdirAll(filepath.Dir(s.options.StorePath), 0o755); err != nil {
return core.Result{Value: err, OK: false}
}
2026-04-15 18:46:14 +01:00
keyValueStore, err := store.NewConfigured(store.StoreConfig{DatabasePath: s.options.StorePath})
if err != nil {
return core.Result{Value: err, OK: false}
}
s.store = keyValueStore
s.toolExecutor = s.options.ToolExecutor
if s.toolExecutor == nil {
subsystem := guimcp.New(s.Core())
server := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "coregui-chat", Version: "0.1.0"}, nil)
subsystem.RegisterTools(server)
s.toolExecutor = subsystem
}
s.toolHandler = NewToolCallHandler(s.toolExecutor)
s.Core().RegisterQuery(s.handleQuery)
s.registerActions()
return core.Result{OK: true}
}
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
return core.Result{OK: true}
}
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
switch typed := q.(type) {
case QueryHistory:
conv, err := s.getConversation(typed.ID, typed.ConversationID)
return core.Result{}.New(conv, err)
case QueryModels:
return core.Result{Value: s.discoverModels(), OK: true}
case QuerySettings:
return core.Result{Value: s.loadSettings(), OK: true}
case QueryConversationList:
conversations, err := s.listConversationSummaries()
return core.Result{}.New(conversations, err)
case QueryConversationGet:
conv, err := s.getConversation(typed.ID, typed.ConversationID)
return core.Result{}.New(conv, err)
case QueryConversationSearch:
results, err := s.searchConversationSummaries(typed.Query)
return core.Result{}.New(results, err)
default:
return core.Result{}
}
}
func (s *Service) registerActions() {
c := s.Core()
c.Action("gui.chat.send", func(ctx context.Context, opts core.Options) core.Result {
input, err := decodeInput[sendInput](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
conv, err := s.send(ctx, input)
return core.Result{}.New(conv, err)
})
c.Action("gui.chat.clear", 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.clearConversation(input.ID, input.ConversationID)
return core.Result{}.New(conv, err)
})
c.Action("gui.chat.history", 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)
return core.Result{}.New(conv, err)
})
c.Action("gui.chat.models", func(_ context.Context, _ core.Options) core.Result {
return core.Result{Value: s.discoverModels(), 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)
return core.Result{}.New(settings, err)
})
c.Action("gui.chat.settings.save", func(_ context.Context, opts core.Options) core.Result {
settings, err := decodeInput[ChatSettings](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
err = s.saveSettings(settings)
return core.Result{}.New(settings, err)
})
c.Action("gui.chat.settings.load", func(_ context.Context, _ core.Options) core.Result {
return core.Result{Value: s.loadSettings(), OK: true}
})
c.Action("gui.chat.settings.reset", func(_ context.Context, _ core.Options) core.Result {
settings := DefaultSettings()
err := s.saveSettings(settings)
return core.Result{}.New(settings, err)
})
c.Action("gui.chat.conversations.list", func(_ context.Context, _ core.Options) core.Result {
conversations, err := s.listConversationSummaries()
return core.Result{}.New(conversations, err)
})
c.Action("gui.chat.conversations.get", 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)
return core.Result{}.New(conv, err)
})
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)
})
c.Action("gui.chat.conversations.search", func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[searchInput](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
results, err := s.searchConversationSummaries(input.Query)
return core.Result{}.New(results, err)
})
c.Action("gui.chat.conversations.new", func(_ context.Context, _ core.Options) core.Result {
conv, err := s.createConversation()
return core.Result{}.New(conv, err)
})
c.Action("gui.chat.conversation.save", func(_ context.Context, opts core.Options) core.Result {
conv, err := decodeInput[Conversation](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
if strings.TrimSpace(conv.ID) == "" {
return core.Result{Value: coreerr.E("chat.conversation.save", "conversation id is required", nil), OK: false}
}
saved, err := s.saveConversation(conv)
if err != nil {
return core.Result{Value: err, OK: false}
}
s.emit(ActionConversationUpdated{Conversation: saved})
return core.Result{Value: saved, OK: true}
})
c.Action("gui.chat.conversations.rename", func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[renameInput](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
conv, err := s.renameConversation(input.ID, input.Title)
return core.Result{}.New(conv, err)
})
c.Action("gui.chat.conversations.export", func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[conversationInput](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
markdown, err := s.exportConversation(coalesce(input.ID, input.ConversationID))
return core.Result{}.New(markdown, err)
})
c.Action("gui.chat.attachImage", func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[attachImageInput](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
s.queueAttachment(coalesce(input.ConversationID, "draft"), input.ImageAttachment)
return core.Result{Value: input.ImageAttachment, OK: true}
})
c.Action("gui.chat.removeImage", func(_ context.Context, opts core.Options) core.Result {
input, err := decodeInput[removeImageInput](opts)
if err != nil {
return core.Result{Value: err, OK: false}
}
attachment, err := s.removeAttachment(coalesce(input.ConversationID, "draft"), input.Index)
return core.Result{}.New(attachment, err)
})
c.Action("gui.chat.thinking.start", 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.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}
})
c.Action("gui.chat.thinking.append", 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.append", "conversation id is required", nil), OK: false}
}
s.emit(ActionThinkingAppended{
ConversationID: input.ConversationID,
MessageID: input.MessageID,
Content: input.Content,
})
return core.Result{Value: input.Content, OK: true}
})
c.Action("gui.chat.thinking.end", 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}
})
}
func decodeInput[T any](opts core.Options) (T, error) {
var input T
if task := opts.Get("task"); task.OK {
if typed, ok := task.Value.(T); ok {
return typed, nil
}
}
items := make(map[string]any, opts.Len())
for _, item := range opts.Items() {
items[item.Key] = item.Value
}
if len(items) == 0 {
return input, nil
}
result := core.JSONUnmarshalString(core.JSONMarshalString(items), &input)
if !result.OK {
if err, ok := result.Value.(error); ok {
return input, err
}
return input, coreerr.E("chat.decodeInput", "failed to decode action input", nil)
}
return input, nil
}
func defaultModelRoots() []string {
roots := []string{filepath.Join(core.Env("DIR_HOME"), ".core", "models")}
if env := strings.TrimSpace(core.Env("CORE_MODELS_DIR")); env != "" {
roots = append(roots, env)
}
return roots
}
func (s *Service) now() time.Time {
if s.options.Now != nil {
return s.options.Now()
}
return time.Now()
}
func (s *Service) saveSettings(settings ChatSettings) error {
payload := core.JSONMarshalString(settings)
return s.store.Set(settingsGroup, settingsKey, payload)
}
func (s *Service) loadSettings() ChatSettings {
settings := DefaultSettings()
if s.store == nil {
return settings
}
payload, err := s.store.Get(settingsGroup, settingsKey)
if err != nil {
return settings
}
_ = core.JSONUnmarshalString(payload, &settings)
return settings
}
func (s *Service) selectModel(input selectModelInput) (ChatSettings, error) {
settings := s.loadSettings()
settings.DefaultModel = input.Model
if err := s.saveSettings(settings); err != nil {
return ChatSettings{}, err
}
targetConversation := coalesce(input.ConversationID, input.ID)
if targetConversation == "" {
return settings, nil
}
conv, err := s.loadConversation(targetConversation)
if err != nil {
return ChatSettings{}, err
}
conv.Model = input.Model
conv, err = s.saveConversation(conv)
if err != nil {
return ChatSettings{}, err
}
s.emit(ActionConversationUpdated{Conversation: conv})
return settings, nil
}
func (s *Service) saveConversation(conv Conversation) (Conversation, error) {
if conv.CreatedAt.IsZero() {
conv.CreatedAt = s.now()
}
if strings.TrimSpace(conv.Title) == "" {
if len(conv.Messages) > 0 && strings.TrimSpace(conv.Messages[0].Content) != "" {
conv.Title = titleFrom(conv.Messages[0].Content)
} else {
conv.Title = "New Chat"
}
}
conv.UpdatedAt = s.now()
payload := core.JSONMarshalString(conv)
return conv, s.store.Set(conversationsGroup, conv.ID, payload)
}
func (s *Service) loadConversation(id string) (Conversation, error) {
payload, err := s.store.Get(conversationsGroup, id)
if err != nil {
return Conversation{}, err
}
var conv Conversation
result := core.JSONUnmarshalString(payload, &conv)
if !result.OK {
if decodeErr, ok := result.Value.(error); ok {
return Conversation{}, decodeErr
}
return Conversation{}, coreerr.E("chat.loadConversation", "failed to decode conversation", nil)
}
return conv, nil
}
func (s *Service) listConversationSummaries() ([]ConversationSummary, error) {
if s.store == nil {
return nil, nil
}
items, err := s.store.GetAll(conversationsGroup)
if err != nil {
return nil, err
}
summaries := make([]ConversationSummary, 0, len(items))
for _, payload := range items {
var conv Conversation
if result := core.JSONUnmarshalString(payload, &conv); result.OK {
summaries = append(summaries, conv.Summary())
}
}
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt)
})
return summaries, nil
}
func (s *Service) searchConversationSummaries(query string) ([]ConversationSummary, error) {
query = strings.TrimSpace(strings.ToLower(query))
summaries, err := s.listConversationSummaries()
if err != nil || query == "" {
return summaries, err
}
items, err := s.store.GetAll(conversationsGroup)
if err != nil {
return nil, err
}
matches := make([]ConversationSummary, 0)
for _, payload := range items {
var conv Conversation
if result := core.JSONUnmarshalString(payload, &conv); !result.OK {
continue
}
if strings.Contains(strings.ToLower(conv.Title), query) {
matches = append(matches, conv.Summary())
continue
}
for _, message := range conv.Messages {
if strings.Contains(strings.ToLower(message.Content), query) {
matches = append(matches, conv.Summary())
break
}
}
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].UpdatedAt.After(matches[j].UpdatedAt)
})
return matches, nil
}
func (s *Service) createConversation() (Conversation, error) {
settings := s.loadSettings()
now := s.now()
conv := Conversation{
ID: "conv-" + strconv.FormatInt(now.UnixNano(), 36),
Title: "New Chat",
Model: settings.DefaultModel,
CreatedAt: now,
UpdatedAt: now,
Messages: nil,
}
conv, err := s.saveConversation(conv)
if err != nil {
return Conversation{}, err
}
s.emit(ActionConversationCreated{Conversation: conv})
return conv, nil
}
func (s *Service) getConversation(id, conversationID string) (Conversation, error) {
target := coalesce(id, conversationID)
if target == "" {
return Conversation{}, coreerr.E("chat.getConversation", "conversation id is required", nil)
}
return s.loadConversation(target)
}
func (s *Service) renameConversation(id, title string) (Conversation, error) {
conv, err := s.loadConversation(id)
if err != nil {
return Conversation{}, err
}
conv.Title = strings.TrimSpace(title)
if conv.Title == "" {
conv.Title = "New Chat"
}
conv, err = s.saveConversation(conv)
if err != nil {
return Conversation{}, err
}
s.emit(ActionConversationUpdated{Conversation: conv})
return conv, nil
}
func (s *Service) clearConversation(id, conversationID string) (Conversation, error) {
conv, err := s.getConversation(id, conversationID)
if err != nil {
return Conversation{}, err
}
conv.Messages = nil
conv.UpdatedAt = s.now()
conv, err = s.saveConversation(conv)
if err != nil {
return Conversation{}, err
}
s.clearQueuedAttachments(conv.ID)
s.emit(ActionConversationCleared{ConversationID: conv.ID})
s.emit(ActionConversationUpdated{Conversation: conv})
return conv, nil
}
func (s *Service) deleteConversation(id string) error {
if id == "" {
return coreerr.E("chat.deleteConversation", "conversation id is required", nil)
}
if err := s.store.Delete(conversationsGroup, id); err != nil {
return err
}
s.clearQueuedAttachments(id)
s.emit(ActionConversationDeleted{ConversationID: id})
return nil
}
func (s *Service) exportConversation(id string) (string, error) {
conv, err := s.loadConversation(id)
if err != nil {
return "", err
}
var builder strings.Builder
builder.WriteString("# ")
builder.WriteString(conv.Title)
builder.WriteString("\n\n")
for _, message := range conv.Messages {
builder.WriteString("## ")
builder.WriteString(strings.Title(message.Role))
builder.WriteString("\n\n")
if message.Content != "" {
builder.WriteString(message.Content)
builder.WriteString("\n\n")
}
for _, result := range message.ToolResults {
builder.WriteString("> Tool Result (`")
builder.WriteString(result.ToolCallID)
builder.WriteString("`)\n>\n> ")
builder.WriteString(strings.ReplaceAll(result.Content, "\n", "\n> "))
builder.WriteString("\n\n")
}
}
return builder.String(), nil
}
func (s *Service) queueAttachment(conversationID string, attachment ImageAttachment) {
key := coalesce(conversationID, "draft")
s.mu.Lock()
defer s.mu.Unlock()
s.pendingAttachments[key] = append(s.pendingAttachments[key], attachment)
s.emit(ActionImageQueued{ConversationID: key, Attachment: attachment})
}
func (s *Service) drainAttachments(conversationID string) []ImageAttachment {
s.mu.Lock()
defer s.mu.Unlock()
var attachments []ImageAttachment
keys := []string{conversationID}
if conversationID != "draft" {
keys = append(keys, "draft")
}
for _, key := range keys {
if key == "" {
continue
}
attachments = append(attachments, s.pendingAttachments[key]...)
delete(s.pendingAttachments, key)
}
return attachments
}
// removeAttachment removes a queued image by index from the pending attachment queue.
// Use: removed, _ := service.removeAttachment("draft", 0)
func (s *Service) removeAttachment(conversationID string, index int) (ImageAttachment, error) {
key := coalesce(conversationID, "draft")
if index < 0 {
return ImageAttachment{}, coreerr.E("chat.removeAttachment", "attachment index must be non-negative", nil)
}
s.mu.Lock()
defer s.mu.Unlock()
attachments := s.pendingAttachments[key]
if index >= len(attachments) {
return ImageAttachment{}, coreerr.E("chat.removeAttachment", "attachment index is out of range", nil)
}
removed := attachments[index]
next := append(attachments[:index:index], attachments[index+1:]...)
if len(next) == 0 {
delete(s.pendingAttachments, key)
} else {
s.pendingAttachments[key] = next
}
return removed, nil
}
func (s *Service) clearQueuedAttachments(conversationID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.pendingAttachments, conversationID)
if conversationID != "draft" {
delete(s.pendingAttachments, "draft")
}
}
func (s *Service) mergedSettings(global ChatSettings, override *ChatSettings) ChatSettings {
if override == nil {
return global
}
merged := global
if override.Temperature != 0 {
merged.Temperature = override.Temperature
}
if override.TopP != 0 {
merged.TopP = override.TopP
}
if override.TopK != 0 {
merged.TopK = override.TopK
}
if override.MaxTokens != 0 {
merged.MaxTokens = override.MaxTokens
}
if override.ContextWindow != 0 {
merged.ContextWindow = override.ContextWindow
}
if override.SystemPrompt != "" {
merged.SystemPrompt = override.SystemPrompt
}
if override.DefaultModel != "" {
merged.DefaultModel = override.DefaultModel
}
return merged
}
func (s *Service) send(ctx context.Context, input sendInput) (Conversation, error) {
if strings.TrimSpace(input.Content) == "" && !s.hasPendingAttachments(input.ConversationID) {
return Conversation{}, coreerr.E("chat.send", "message content is required", nil)
}
settings := s.loadSettings()
var (
conv Conversation
err error
created bool
)
if input.ConversationID != "" {
conv, err = s.loadConversation(input.ConversationID)
} else {
conv, err = s.createConversation()
created = true
}
if err != nil {
return Conversation{}, err
}
attachments := s.drainAttachments(conv.ID)
if len(attachments) == 0 && created {
attachments = s.drainAttachments("draft")
}
now := s.now()
conv.Model = s.resolveModel(conv.Model, settings.DefaultModel)
userMessage := ChatMessage{
ID: "msg-" + strconv.FormatInt(now.UnixNano(), 36),
Role: "user",
Content: input.Content,
CreatedAt: now,
Model: conv.Model,
Attachments: attachments,
}
conv.Messages = append(conv.Messages, userMessage)
if conv.Title == "" || conv.Title == "New Chat" {
conv.Title = titleFrom(input.Content)
}
conv, err = s.saveConversation(conv)
if err != nil {
return Conversation{}, err
}
s.emit(ActionMessageAdded{ConversationID: conv.ID, Message: userMessage})
s.emit(ActionConversationUpdated{Conversation: conv})
for toolRound := 0; toolRound < 3; toolRound++ {
effectiveSettings := s.mergedSettings(settings, conv.Settings)
conv.Model = s.resolveModel(conv.Model, effectiveSettings.DefaultModel)
assistantMessage, err := s.streamAssistant(ctx, conv, effectiveSettings)
if err != nil {
return conv, err
}
if hasRenderableContent(assistantMessage) {
conv.Messages = append(conv.Messages, assistantMessage)
conv, err = s.saveConversation(conv)
if err != nil {
return conv, err
}
s.emit(ActionMessageAdded{ConversationID: conv.ID, Message: assistantMessage})
s.emit(ActionConversationUpdated{Conversation: conv})
}
if len(assistantMessage.ToolCalls) == 0 {
break
}
results := s.toolHandler.ExecuteAll(ctx, assistantMessage.ToolCalls)
for _, result := range results {
toolMessage := ChatMessage{
ID: "tool-" + strconv.FormatInt(s.now().UnixNano(), 36),
Role: "tool",
Content: result.Content,
CreatedAt: s.now(),
Model: conv.Model,
ToolCallID: result.ToolCallID,
ToolResults: []ToolResult{result},
}
conv.Messages = append(conv.Messages, toolMessage)
s.emit(ActionToolResultReady{ConversationID: conv.ID, MessageID: assistantMessage.ID, Result: result})
s.emit(ActionMessageAdded{ConversationID: conv.ID, Message: toolMessage})
}
conv, err = s.saveConversation(conv)
if err != nil {
return conv, err
}
s.emit(ActionConversationUpdated{Conversation: conv})
}
return conv, nil
}
func (s *Service) hasPendingAttachments(conversationID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if attachments := s.pendingAttachments[coalesce(conversationID, "draft")]; len(attachments) > 0 {
return true
}
if conversationID != "" && len(s.pendingAttachments["draft"]) > 0 {
return true
}
return false
}
func (s *Service) streamAssistant(ctx context.Context, conv Conversation, settings ChatSettings) (ChatMessage, error) {
messageID := "msg-" + strconv.FormatInt(s.now().UnixNano(), 36)
requestBody := s.buildCompletionRequest(conv, settings)
payload := core.JSONMarshalString(requestBody)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.options.APIURL, "/")+"/v1/chat/completions", bytes.NewBufferString(payload))
if err != nil {
return ChatMessage{}, err
}
request.Header.Set("Content-Type", "application/json")
response, err := s.httpClient.Do(request)
if err != nil {
return ChatMessage{}, err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
body, _ := io.ReadAll(response.Body)
return ChatMessage{}, coreerr.E("chat.streamAssistant", strings.TrimSpace(string(body)), nil)
}
renderer := NewStreamRenderer(StreamCallbacks{
OnStart: func(streamID string) {
s.emit(ActionStreamStarted{ConversationID: conv.ID, MessageID: messageID, StreamID: streamID})
},
OnToken: func(content string) {
s.emit(ActionTokenAppended{ConversationID: conv.ID, MessageID: messageID, Content: content})
},
OnThinkingStart: func(state ThinkingState) {
s.emit(ActionThinkingStarted{ConversationID: conv.ID, MessageID: messageID, StartedAt: state.StartedAt})
},
OnThinkingAppend: func(content string) {
s.emit(ActionThinkingAppended{ConversationID: conv.ID, MessageID: messageID, Content: content})
},
OnThinkingEnd: func(state ThinkingState) {
s.emit(ActionThinkingEnded{ConversationID: conv.ID, MessageID: messageID, DurationMS: state.DurationMS})
},
OnToolCall: func(call ToolCall) {
s.emit(ActionToolCallStarted{ConversationID: conv.ID, MessageID: messageID, Call: call})
},
OnFinish: func(reason string) {
s.emit(ActionStreamFinished{ConversationID: conv.ID, MessageID: messageID, FinishReason: reason})
},
})
if err := renderer.Render(response.Body); err != nil {
return ChatMessage{}, err
}
return renderer.Message(messageID, conv.Model, s.now()), nil
}
func (s *Service) buildCompletionRequest(conv Conversation, settings ChatSettings) openAIRequest {
request := openAIRequest{
Model: s.resolveModel(conv.Model, settings.DefaultModel),
Messages: make([]openAIMessage, 0, len(conv.Messages)+1),
Temperature: settings.Temperature,
TopP: settings.TopP,
TopK: settings.TopK,
MaxTokens: settings.MaxTokens,
Stream: true,
}
systemPrompt := strings.TrimSpace(settings.SystemPrompt)
if s.toolExecutor != nil {
manifest := s.toolExecutor.ManifestText()
if manifest != "" {
if systemPrompt != "" {
systemPrompt += "\n\n"
}
systemPrompt += manifest + "\nUse tools when helpful. When a tool is needed, emit a tool call with valid JSON arguments."
for _, tool := range s.toolExecutor.Manifest() {
request.Tools = append(request.Tools, openAIToolSpec{
Type: "function",
Function: openAIFunctionSpec{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.InputSchema,
},
})
}
request.ToolChoice = "auto"
}
}
if systemPrompt != "" {
request.Messages = append(request.Messages, openAIMessage{
Role: "system",
Content: systemPrompt,
})
}
for _, message := range conv.Messages {
apiMessage := openAIMessage{Role: message.Role}
switch message.Role {
case "user":
apiMessage.Content = renderUserContent(message)
case "assistant":
apiMessage.Content = message.Content
if len(message.ToolCalls) > 0 {
apiMessage.ToolCalls = make([]openAIToolCall, 0, len(message.ToolCalls))
for _, call := range message.ToolCalls {
apiMessage.ToolCalls = append(apiMessage.ToolCalls, openAIToolCall{
ID: call.ID,
Type: "function",
Function: openAIFunctionCall{
Name: call.Name,
Arguments: core.JSONMarshalString(call.Arguments),
},
})
}
}
case "tool":
apiMessage.Content = message.Content
apiMessage.ToolCallID = message.ToolCallID
default:
apiMessage.Content = message.Content
}
request.Messages = append(request.Messages, apiMessage)
}
return request
}
func (s *Service) resolveModel(current, configured string) string {
if current = strings.TrimSpace(current); current != "" {
return current
}
if configured = strings.TrimSpace(configured); configured != "" {
return configured
}
models := s.discoverModels()
if len(models) > 0 {
return models[0].Name
}
return "default"
}
func renderUserContent(message ChatMessage) any {
if len(message.Attachments) == 0 {
return message.Content
}
parts := []map[string]any{
{"type": "text", "text": message.Content},
}
for _, attachment := range message.Attachments {
parts = append(parts, map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": "data:" + attachment.MimeType + ";base64," + attachment.Data,
},
})
}
return parts
}
func hasRenderableContent(message ChatMessage) bool {
return strings.TrimSpace(message.Content) != "" || message.Thinking != nil || len(message.ToolCalls) > 0
}
func titleFrom(content string) string {
title := strings.TrimSpace(content)
if title == "" {
return "New Chat"
}
runes := []rune(title)
if len(runes) > 50 {
return string(runes[:50])
}
return title
}
func coalesce(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func (s *Service) emit(message any) {
if message == nil {
return
}
_ = s.Core().ACTION(message)
}
func (s *Service) discoverModels() []ModelEntry {
settings := s.loadSettings()
models := map[string]ModelEntry{}
for _, root := range s.options.ModelRoots {
for _, model := range discoverModelsOnDisk(root) {
models[model.Name] = model
}
}
configPath := filepath.Join(core.Env("DIR_HOME"), ".core", "models.yaml")
if payload, err := os.ReadFile(configPath); err == nil {
var configured configuredModels
if err := yaml.Unmarshal(payload, &configured); err == nil {
defaultModel := coalesce(configured.DefaultModel, configured.Default, settings.DefaultModel)
for _, item := range configured.Models {
name := coalesce(item.Name, filepath.Base(item.Path))
entry := models[name]
entry.Name = name
entry.Architecture = coalesce(item.Architecture, entry.Architecture)
entry.Backend = coalesce(item.Backend, entry.Backend, "local")
if entry.SizeBytes == 0 && item.Path != "" {
entry.SizeBytes = directorySize(item.Path)
}
entry.Loaded = name == defaultModel
models[name] = entry
}
}
}
names := make([]string, 0, len(models))
for name := range models {
names = append(names, name)
}
slices.Sort(names)
results := make([]ModelEntry, 0, len(names))
for _, name := range names {
results = append(results, models[name])
}
return results
}
func discoverModelsOnDisk(root string) []ModelEntry {
if strings.TrimSpace(root) == "" {
return nil
}
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
var results []ModelEntry
for _, entry := range entries {
if !entry.IsDir() {
continue
}
modelPath := filepath.Join(root, entry.Name())
configPath := filepath.Join(modelPath, "config.json")
if _, err := os.Stat(configPath); err != nil {
continue
}
results = append(results, ModelEntry{
Name: entry.Name(),
Architecture: architectureFromConfig(configPath),
QuantBits: quantBitsFromName(entry.Name()),
SizeBytes: directorySize(modelPath),
Backend: "local",
})
}
return results
}
func architectureFromConfig(configPath string) string {
payload, err := os.ReadFile(configPath)
if err != nil {
return ""
}
var parsed map[string]any
if result := core.JSONUnmarshalString(string(payload), &parsed); !result.OK {
return ""
}
if architectures, ok := parsed["architectures"].([]any); ok && len(architectures) > 0 {
if name, ok := architectures[0].(string); ok {
return strings.ToLower(name)
}
}
if modelType, ok := parsed["model_type"].(string); ok {
return strings.ToLower(modelType)
}
return ""
}
func quantBitsFromName(name string) int {
lower := strings.ToLower(name)
switch {
case strings.Contains(lower, "q4"), strings.Contains(lower, "4bit"):
return 4
case strings.Contains(lower, "q8"), strings.Contains(lower, "8bit"):
return 8
default:
return 0
}
}
func directorySize(root string) int64 {
var total int64
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() {
return nil
}
if strings.HasSuffix(info.Name(), ".safetensors") || strings.HasSuffix(info.Name(), ".gguf") || strings.HasSuffix(info.Name(), ".bin") {
total += info.Size()
}
return nil
})
return total
}