diff options
Diffstat (limited to 'bot.go')
| -rw-r--r-- | bot.go | 911 |
1 files changed, 782 insertions, 129 deletions
@@ -3,194 +3,750 @@ package main import ( "bufio" "bytes" - "elefant/models" - "elefant/storage" + "context" "encoding/json" "fmt" + "gf-lt/config" + "gf-lt/extra" + "gf-lt/models" + "gf-lt/rag" + "gf-lt/storage" + "html" "io" "log/slog" + "net" "net/http" "os" + "path" "strings" "time" + "github.com/neurosnap/sentences/english" "github.com/rivo/tview" ) -var httpClient = http.Client{ - Timeout: time.Second * 20, -} - var ( - logger *slog.Logger - APIURL = "http://localhost:8080/v1/chat/completions" - DB = map[string]map[string]any{} - userRole = "user" - assistantRole = "assistant" - toolRole = "tool" - assistantIcon = "<🤖>: " - userIcon = "<user>: " - historyDir = "./history/" - // TODO: pass as an cli arg - showSystemMsgs bool - activeChatName string - chunkChan = make(chan string, 10) - streamDone = make(chan bool, 1) - chatBody *models.ChatBody - store storage.ChatHistory - defaultFirstMsg = "Hello! What can I do for you?" - defaultStarter = []models.MessagesStory{ - {Role: "system", Content: systemMsg}, - {Role: assistantRole, Content: defaultFirstMsg}, - } - interruptResp = false + httpClient = &http.Client{} + cluedoState *extra.CluedoRoundInfo // Current game state + playerOrder []string // Turn order tracking + cfg *config.Config + logger *slog.Logger + logLevel = new(slog.LevelVar) +) +var ( + activeChatName string + chunkChan = make(chan string, 10) + openAIToolChan = make(chan string, 10) + streamDone = make(chan bool, 1) + chatBody *models.ChatBody + store storage.FullRepo + defaultFirstMsg = "Hello! What can I do for you?" + defaultStarter = []models.RoleMsg{} + defaultStarterBytes = []byte{} + interruptResp = false + ragger *rag.RAG + chunkParser ChunkParser + lastToolCall *models.FuncCall + lastToolCallID string // Store the ID of the most recent tool call + //nolint:unused // TTS_ENABLED conditionally uses this + orator extra.Orator + asr extra.STT + defaultLCPProps = map[string]float32{ + "temperature": 0.8, + "dry_multiplier": 0.0, + "min_p": 0.05, + "n_predict": -1.0, + } + ORFreeModels = []string{ + "google/gemini-2.0-flash-exp:free", + "deepseek/deepseek-chat-v3-0324:free", + "mistralai/mistral-small-3.2-24b-instruct:free", + "qwen/qwen3-14b:free", + "google/gemma-3-27b-it:free", + "meta-llama/llama-3.3-70b-instruct:free", + } + LocalModels = []string{} ) -// ==== +// cleanNullMessages removes messages with null or empty content to prevent API issues +func cleanNullMessages(messages []models.RoleMsg) []models.RoleMsg { + // // deletes tool calls which we don't want for now + // cleaned := make([]models.RoleMsg, 0, len(messages)) + // for _, msg := range messages { + // // is there a sense for this check at all? + // if msg.HasContent() || msg.ToolCallID != "" || msg.Role == cfg.AssistantRole || msg.Role == cfg.WriteNextMsgAsCompletionAgent { + // cleaned = append(cleaned, msg) + // } else { + // // Log filtered messages for debugging + // logger.Warn("filtering out message during cleaning", "role", msg.Role, "content", msg.Content, "tool_call_id", msg.ToolCallID, "has_content", msg.HasContent()) + // } + // } + return consolidateConsecutiveAssistantMessages(messages) +} + +// consolidateConsecutiveAssistantMessages merges consecutive assistant messages into a single message +func consolidateConsecutiveAssistantMessages(messages []models.RoleMsg) []models.RoleMsg { + if len(messages) == 0 { + return messages + } -func getUserInput(userPrompt string) string { - fmt.Printf(userPrompt) - reader := bufio.NewReader(os.Stdin) - line, err := reader.ReadString('\n') + consolidated := make([]models.RoleMsg, 0, len(messages)) + currentAssistantMsg := models.RoleMsg{} + isBuildingAssistantMsg := false + + for i := 0; i < len(messages); i++ { + msg := messages[i] + + if msg.Role == cfg.AssistantRole || msg.Role == cfg.WriteNextMsgAsCompletionAgent { + // If this is an assistant message, start or continue building + if !isBuildingAssistantMsg { + // Start accumulating assistant message + currentAssistantMsg = msg.Copy() + isBuildingAssistantMsg = true + } else { + // Continue accumulating - append content to the current assistant message + if currentAssistantMsg.IsContentParts() || msg.IsContentParts() { + // Handle structured content + if !currentAssistantMsg.IsContentParts() { + // Preserve the original ToolCallID before conversion + originalToolCallID := currentAssistantMsg.ToolCallID + // Convert existing content to content parts + currentAssistantMsg = models.NewMultimodalMsg(currentAssistantMsg.Role, []interface{}{models.TextContentPart{Type: "text", Text: currentAssistantMsg.Content}}) + // Restore the original ToolCallID to preserve tool call linking + currentAssistantMsg.ToolCallID = originalToolCallID + } + if msg.IsContentParts() { + currentAssistantMsg.ContentParts = append(currentAssistantMsg.ContentParts, msg.GetContentParts()...) + } else if msg.Content != "" { + currentAssistantMsg.AddTextPart(msg.Content) + } + } else { + // Simple string content + if currentAssistantMsg.Content != "" { + currentAssistantMsg.Content += "\n" + msg.Content + } else { + currentAssistantMsg.Content = msg.Content + } + // ToolCallID is already preserved since we're not creating a new message object when just concatenating content + } + } + } else { + // This is not an assistant message + // If we were building an assistant message, add it to the result + if isBuildingAssistantMsg { + consolidated = append(consolidated, currentAssistantMsg) + isBuildingAssistantMsg = false + } + // Add the non-assistant message + consolidated = append(consolidated, msg) + } + } + + // Don't forget the last assistant message if we were building one + if isBuildingAssistantMsg { + consolidated = append(consolidated, currentAssistantMsg) + } + + return consolidated +} + +// GetLogLevel returns the current log level as a string +func GetLogLevel() string { + level := logLevel.Level() + switch level { + case slog.LevelDebug: + return "Debug" + case slog.LevelInfo: + return "Info" + case slog.LevelWarn: + return "Warn" + default: + // For any other values, return "Info" as default + return "Info" + } +} + +func createClient(connectTimeout time.Duration) *http.Client { + // Custom transport with connection timeout + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // Create a dialer with connection timeout + dialer := &net.Dialer{ + Timeout: connectTimeout, + KeepAlive: 30 * time.Second, // Optional + } + return dialer.DialContext(ctx, network, addr) + }, + // Other transport settings (optional) + TLSHandshakeTimeout: connectTimeout, + ResponseHeaderTimeout: connectTimeout, + } + // Client with no overall timeout (or set to streaming-safe duration) + return &http.Client{ + Transport: transport, + Timeout: 0, // No overall timeout (for streaming) + } +} + +func fetchLCPModelName() *models.LCPModels { + //nolint + resp, err := httpClient.Get(cfg.FetchModelNameAPI) if err != nil { - panic(err) // think about it + chatBody.Model = "disconnected" + logger.Warn("failed to get model", "link", cfg.FetchModelNameAPI, "error", err) + if err := notifyUser("error", "request failed "+cfg.FetchModelNameAPI); err != nil { + logger.Debug("failed to notify user", "error", err, "fn", "fetchLCPModelName") + } + return nil + } + defer resp.Body.Close() + llmModel := models.LCPModels{} + if err := json.NewDecoder(resp.Body).Decode(&llmModel); err != nil { + logger.Warn("failed to decode resp", "link", cfg.FetchModelNameAPI, "error", err) + return nil + } + if resp.StatusCode != 200 { + chatBody.Model = "disconnected" + return nil } - return line + chatBody.Model = path.Base(llmModel.Data[0].ID) + return &llmModel } -func formMsg(chatBody *models.ChatBody, newMsg, role string) io.Reader { - if newMsg != "" { // otherwise let the bot continue - newMsg := models.MessagesStory{Role: role, Content: newMsg} - chatBody.Messages = append(chatBody.Messages, newMsg) +// nolint +func fetchDSBalance() *models.DSBalance { + url := "https://api.deepseek.com/user/balance" + method := "GET" + // nolint + req, err := http.NewRequest(method, url, nil) + if err != nil { + logger.Warn("failed to create request", "error", err) + return nil } - data, err := json.Marshal(chatBody) + req.Header.Add("Accept", "application/json") + req.Header.Add("Authorization", "Bearer "+cfg.DeepSeekToken) + res, err := httpClient.Do(req) if err != nil { - panic(err) + logger.Warn("failed to make request", "error", err) + return nil + } + defer res.Body.Close() + resp := models.DSBalance{} + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return nil } - return bytes.NewReader(data) + return &resp } -// func sendMsgToLLM(body io.Reader) (*models.LLMRespChunk, error) { -func sendMsgToLLM(body io.Reader) (any, error) { - resp, err := httpClient.Post(APIURL, "application/json", body) +func fetchORModels(free bool) ([]string, error) { + resp, err := http.Get("https://openrouter.ai/api/v1/models") if err != nil { - logger.Error("llamacpp api", "error", err) return nil, err } defer resp.Body.Close() - llmResp := []models.LLMRespChunk{} - // chunkChan <- assistantIcon + if resp.StatusCode != 200 { + err := fmt.Errorf("failed to fetch or models; status: %s", resp.Status) + return nil, err + } + data := &models.ORModels{} + if err := json.NewDecoder(resp.Body).Decode(data); err != nil { + return nil, err + } + freeModels := data.ListModels(free) + return freeModels, nil +} + +func fetchLCPModels() ([]string, error) { + resp, err := http.Get(cfg.FetchModelNameAPI) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + err := fmt.Errorf("failed to fetch or models; status: %s", resp.Status) + return nil, err + } + data := &models.LCPModels{} + if err := json.NewDecoder(resp.Body).Decode(data); err != nil { + return nil, err + } + localModels := data.ListModels() + return localModels, nil +} + +func sendMsgToLLM(body io.Reader) { + choseChunkParser() + + var req *http.Request + var err error + + // Capture and log the request body for debugging + if _, ok := body.(*io.LimitedReader); ok { + // If it's a LimitedReader, we need to handle it differently + logger.Debug("request body type is LimitedReader", "parser", chunkParser, "link", cfg.CurrentAPI) + req, err = http.NewRequest("POST", cfg.CurrentAPI, body) + if err != nil { + logger.Error("newreq error", "error", err) + if err := notifyUser("error", "apicall failed:"+err.Error()); err != nil { + logger.Error("failed to notify", "error", err) + } + streamDone <- true + return + } + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Authorization", "Bearer "+chunkParser.GetToken()) + req.Header.Set("Accept-Encoding", "gzip") + } else { + // For other reader types, capture and log the body content + bodyBytes, err := io.ReadAll(body) + if err != nil { + logger.Error("failed to read request body for logging", "error", err) + // Create request with original body if reading fails + req, err = http.NewRequest("POST", cfg.CurrentAPI, bytes.NewReader(bodyBytes)) + if err != nil { + logger.Error("newreq error", "error", err) + if err := notifyUser("error", "apicall failed:"+err.Error()); err != nil { + logger.Error("failed to notify", "error", err) + } + streamDone <- true + return + } + } else { + // Log the request body for debugging + logger.Debug("sending request to API", "api", cfg.CurrentAPI, "body", string(bodyBytes)) + // Create request with the captured body + req, err = http.NewRequest("POST", cfg.CurrentAPI, bytes.NewReader(bodyBytes)) + if err != nil { + logger.Error("newreq error", "error", err) + if err := notifyUser("error", "apicall failed:"+err.Error()); err != nil { + logger.Error("failed to notify", "error", err) + } + streamDone <- true + return + } + } + + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Authorization", "Bearer "+chunkParser.GetToken()) + req.Header.Set("Accept-Encoding", "gzip") + } + // nolint + resp, err := httpClient.Do(req) + if err != nil { + logger.Error("llamacpp api", "error", err) + if err := notifyUser("error", "apicall failed:"+err.Error()); err != nil { + logger.Error("failed to notify", "error", err) + } + streamDone <- true + return + } + defer resp.Body.Close() reader := bufio.NewReader(resp.Body) - counter := 0 + counter := uint32(0) for { - if interruptResp { - interruptResp = false - logger.Info("interrupted bot response") - break - } - llmchunk := models.LLMRespChunk{} - if counter > 2000 { + var ( + answerText string + chunk *models.TextChunk + ) + counter++ + // to stop from spiriling in infinity read of bad bytes that happens with poor connection + if cfg.ChunkLimit > 0 && counter > cfg.ChunkLimit { + logger.Warn("response hit chunk limit", "limit", cfg.ChunkLimit) streamDone <- true break } line, err := reader.ReadBytes('\n') if err != nil { + logger.Error("error reading response body", "error", err, "line", string(line), + "user_role", cfg.UserRole, "parser", chunkParser, "link", cfg.CurrentAPI) + // if err.Error() != "EOF" { + if err := notifyUser("API error", err.Error()); err != nil { + logger.Error("failed to notify", "error", err) + } streamDone <- true - panic(err) + break + // } + // continue } - // logger.Info("linecheck", "line", string(line), "len", len(line), "counter", counter) if len(line) <= 1 { + if interruptResp { + goto interrupt // get unstuck from bad connection + } continue // skip \n } // starts with -> data: line = line[6:] - if err := json.Unmarshal(line, &llmchunk); err != nil { - logger.Error("failed to decode", "error", err, "line", string(line)) + logger.Debug("debugging resp", "line", string(line)) + if bytes.Equal(line, []byte("[DONE]\n")) { streamDone <- true - return nil, err + break + } + if bytes.Equal(line, []byte("ROUTER PROCESSING\n")) { + continue } - llmResp = append(llmResp, llmchunk) - // logger.Info("streamview", "chunk", llmchunk) - // if llmchunk.Choices[len(llmchunk.Choices)-1].FinishReason != "chat.completion.chunk" { - if llmchunk.Choices[len(llmchunk.Choices)-1].FinishReason == "stop" { + chunk, err = chunkParser.ParseChunk(line) + if err != nil { + logger.Error("error parsing response body", "error", err, + "line", string(line), "url", cfg.CurrentAPI) + if err := notifyUser("LLM Response Error", "Failed to parse LLM response: "+err.Error()); err != nil { + logger.Error("failed to notify user", "error", err) + } streamDone <- true - // last chunk break } - counter++ + // Handle error messages in response content + // example needed, since llm could use the word error in the normal msg + // if string(line) != "" && strings.Contains(strings.ToLower(string(line)), "error") { + // logger.Error("API error response detected", "line", line, "url", cfg.CurrentAPI) + // streamDone <- true + // break + // } + if chunk.Finished { + if chunk.Chunk != "" { + logger.Warn("text inside of finish llmchunk", "chunk", chunk, "counter", counter) + answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n") + chunkChan <- answerText + } + streamDone <- true + break + } + if counter == 0 { + chunk.Chunk = strings.TrimPrefix(chunk.Chunk, " ") + } // bot sends way too many \n - answerText := strings.ReplaceAll(llmchunk.Choices[0].Delta.Content, "\n\n", "\n") + answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n") chunkChan <- answerText + openAIToolChan <- chunk.ToolChunk + if chunk.FuncName != "" { + lastToolCall.Name = chunk.FuncName + // Store the tool call ID for the response + lastToolCallID = chunk.ToolID + } + interrupt: + if interruptResp { // read bytes, so it would not get into beginning of the next req + interruptResp = false + logger.Info("interrupted bot response", "chunk_counter", counter) + streamDone <- true + break + } } - return llmResp, nil } -func chatRound(userMsg, role string, tv *tview.TextView) { +func chatRagUse(qText string) (string, error) { + logger.Debug("Starting RAG query", "original_query", qText) + tokenizer, err := english.NewSentenceTokenizer(nil) + if err != nil { + logger.Error("failed to create sentence tokenizer", "error", err) + return "", err + } + // this where llm should find the questions in text and ask them + questionsS := tokenizer.Tokenize(qText) + questions := make([]string, len(questionsS)) + for i, q := range questionsS { + questions[i] = q.Text + logger.Debug("RAG question extracted", "index", i, "question", q.Text) + } + + if len(questions) == 0 { + logger.Warn("No questions extracted from query text", "query", qText) + return "No related results from RAG vector storage.", nil + } + + respVecs := []models.VectorRow{} + for i, q := range questions { + logger.Debug("Processing RAG question", "index", i, "question", q) + emb, err := ragger.LineToVector(q) + if err != nil { + logger.Error("failed to get embeddings for RAG", "error", err, "index", i, "question", q) + continue + } + logger.Debug("Got embeddings for question", "index", i, "question_len", len(q), "embedding_len", len(emb)) + + // Create EmbeddingResp struct for the search + embeddingResp := &models.EmbeddingResp{ + Embedding: emb, + Index: 0, // Not used in search but required for the struct + } + vecs, err := ragger.SearchEmb(embeddingResp) + if err != nil { + logger.Error("failed to query embeddings in RAG", "error", err, "index", i, "question", q) + continue + } + logger.Debug("RAG search returned vectors", "index", i, "question", q, "vector_count", len(vecs)) + respVecs = append(respVecs, vecs...) + } + + // get raw text + resps := []string{} + logger.Debug("RAG query final results", "total_vecs_found", len(respVecs)) + for _, rv := range respVecs { + resps = append(resps, rv.RawText) + logger.Debug("RAG result", "slug", rv.Slug, "filename", rv.FileName, "raw_text_len", len(rv.RawText)) + } + + if len(resps) == 0 { + logger.Info("No RAG results found for query", "original_query", qText, "question_count", len(questions)) + return "No related results from RAG vector storage.", nil + } + + result := strings.Join(resps, "\n") + logger.Debug("RAG query completed", "result_len", len(result), "response_count", len(resps)) + return result, nil +} + +func roleToIcon(role string) string { + return "<" + role + ">: " +} + +// FIXME: it should not be here; move to extra +func checkGame(role string, tv *tview.TextView) { + // Handle Cluedo game flow + // should go before form msg, since formmsg takes chatBody and makes ioreader out of it + // role is almost always user, unless it's regen or resume + // cannot get in this block, since cluedoState is nil; + if cfg.EnableCluedo { + // Initialize Cluedo game if needed + if cluedoState == nil { + playerOrder = []string{cfg.UserRole, cfg.AssistantRole, cfg.CluedoRole2} + cluedoState = extra.CluedoPrepCards(playerOrder) + } + // notifyUser("got in cluedo", "yay") + currentPlayer := playerOrder[0] + playerOrder = append(playerOrder[1:], currentPlayer) // Rotate turns + if role == cfg.UserRole { + fmt.Fprintf(tv, "Your (%s) cards: %s\n", currentPlayer, cluedoState.GetPlayerCards(currentPlayer)) + } else { + chatBody.Messages = append(chatBody.Messages, models.RoleMsg{ + Role: cfg.ToolRole, + Content: cluedoState.GetPlayerCards(currentPlayer), + }) + } + } +} + +func chatRound(userMsg, role string, tv *tview.TextView, regen, resume bool) { botRespMode = true - reader := formMsg(chatBody, userMsg, role) + botPersona := cfg.AssistantRole + if cfg.WriteNextMsgAsCompletionAgent != "" { + botPersona = cfg.WriteNextMsgAsCompletionAgent + } + defer func() { botRespMode = false }() + // check that there is a model set to use if is not local + if cfg.CurrentAPI == cfg.DeepSeekChatAPI || cfg.CurrentAPI == cfg.DeepSeekCompletionAPI { + if chatBody.Model != "deepseek-chat" && chatBody.Model != "deepseek-reasoner" { + if err := notifyUser("bad request", "wrong deepseek model name"); err != nil { + logger.Warn("failed ot notify user", "error", err) + return + } + return + } + } + if !resume { + checkGame(role, tv) + } + choseChunkParser() + reader, err := chunkParser.FormMsg(userMsg, role, resume) + if reader == nil || err != nil { + logger.Error("empty reader from msgs", "role", role, "error", err) + return + } + if cfg.SkipLLMResp { + return + } go sendMsgToLLM(reader) - fmt.Fprintf(tv, fmt.Sprintf("(%d) ", len(chatBody.Messages))) - fmt.Fprintf(tv, assistantIcon) + logger.Debug("looking at vars in chatRound", "msg", userMsg, "regen", regen, "resume", resume) + if !resume { + fmt.Fprintf(tv, "[-:-:b](%d) ", len(chatBody.Messages)) + fmt.Fprint(tv, roleToIcon(botPersona)) + fmt.Fprint(tv, "[-:-:-]\n") + if cfg.ThinkUse && !strings.Contains(cfg.CurrentAPI, "v1") { + // fmt.Fprint(tv, "<think>") + chunkChan <- "<think>" + } + } respText := strings.Builder{} + toolResp := strings.Builder{} out: for { select { case chunk := <-chunkChan: - // fmt.Printf(chunk) - fmt.Fprintf(tv, chunk) + fmt.Fprint(tv, chunk) respText.WriteString(chunk) tv.ScrollToEnd() + // Send chunk to audio stream handler + if cfg.TTS_ENABLED { + // audioStream.TextChan <- chunk + extra.TTSTextChan <- chunk + } + case toolChunk := <-openAIToolChan: + fmt.Fprint(tv, toolChunk) + toolResp.WriteString(toolChunk) + tv.ScrollToEnd() case <-streamDone: + botRespMode = false + if cfg.TTS_ENABLED { + // audioStream.TextChan <- chunk + extra.TTSFlushChan <- true + logger.Debug("sending flushchan signal") + } break out } } botRespMode = false - chatBody.Messages = append(chatBody.Messages, models.MessagesStory{ - Role: assistantRole, Content: respText.String(), - }) + // numbers in chatbody and displayed must be the same + if resume { + chatBody.Messages[len(chatBody.Messages)-1].Content += respText.String() + // lastM.Content = lastM.Content + respText.String() + } else { + chatBody.Messages = append(chatBody.Messages, models.RoleMsg{ + Role: botPersona, Content: respText.String(), + }) + } + + logger.Debug("chatRound: before cleanChatBody", "messages_before_clean", len(chatBody.Messages)) + for i, msg := range chatBody.Messages { + logger.Debug("chatRound: before cleaning", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) + } + + // // Clean null/empty messages to prevent API issues with endpoints like llama.cpp jinja template + cleanChatBody() + + logger.Debug("chatRound: after cleanChatBody", "messages_after_clean", len(chatBody.Messages)) + for i, msg := range chatBody.Messages { + logger.Debug("chatRound: after cleaning", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) + } + + colorText() + updateStatusLine() // bot msg is done; // now check it for func call // logChat(activeChatName, chatBody.Messages) - err := updateStorageChat(activeChatName, chatBody.Messages) - if err != nil { + if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil { logger.Warn("failed to update storage", "error", err, "name", activeChatName) } - findCall(respText.String(), tv) + findCall(respText.String(), toolResp.String(), tv) } -func findCall(msg string, tv *tview.TextView) { - prefix := "__tool_call__\n" - suffix := "\n__tool_call__" - fc := models.FuncCall{} - if !strings.HasPrefix(msg, prefix) || - !strings.HasSuffix(msg, suffix) { - return +// cleanChatBody removes messages with null or empty content to prevent API issues +func cleanChatBody() { + if chatBody != nil && chatBody.Messages != nil { + originalLen := len(chatBody.Messages) + logger.Debug("cleanChatBody: before cleaning", "message_count", originalLen) + for i, msg := range chatBody.Messages { + logger.Debug("cleanChatBody: before clean", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) + } + + chatBody.Messages = cleanNullMessages(chatBody.Messages) + + logger.Debug("cleanChatBody: after cleaning", "original_len", originalLen, "new_len", len(chatBody.Messages)) + for i, msg := range chatBody.Messages { + logger.Debug("cleanChatBody: after clean", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID) + } } - jsStr := strings.TrimSuffix(strings.TrimPrefix(msg, prefix), suffix) - if err := json.Unmarshal([]byte(jsStr), &fc); err != nil { - logger.Error("failed to unmarshal tool call", "error", err) - return - // panic(err) +} + +func findCall(msg, toolCall string, tv *tview.TextView) { + fc := &models.FuncCall{} + if toolCall != "" { + // HTML-decode the tool call string to handle encoded characters like < -> <= + decodedToolCall := html.UnescapeString(toolCall) + openAIToolMap := make(map[string]string) + // respect tool call + if err := json.Unmarshal([]byte(decodedToolCall), &openAIToolMap); err != nil { + logger.Error("failed to unmarshal openai tool call", "call", decodedToolCall, "error", err) + // Send error response to LLM so it can retry or handle the error + toolResponseMsg := models.RoleMsg{ + Role: cfg.ToolRole, + Content: fmt.Sprintf("Error processing tool call: %v. Please check the JSON format and try again.", err), + ToolCallID: lastToolCallID, // Use the stored tool call ID + } + chatBody.Messages = append(chatBody.Messages, toolResponseMsg) + // Clear the stored tool call ID after using it + lastToolCallID = "" + // Trigger the assistant to continue processing with the error message + chatRound("", cfg.AssistantRole, tv, false, false) + return + } + lastToolCall.Args = openAIToolMap + fc = lastToolCall + // Ensure lastToolCallID is set if it's available in the tool call + if lastToolCallID == "" && len(openAIToolMap) > 0 { + // Attempt to extract ID from the parsed tool call if not already set + if id, exists := openAIToolMap["id"]; exists { + lastToolCallID = id + } + } + } else { + jsStr := toolCallRE.FindString(msg) + if jsStr == "" { + return + } + prefix := "__tool_call__\n" + suffix := "\n__tool_call__" + jsStr = strings.TrimSuffix(strings.TrimPrefix(jsStr, prefix), suffix) + // HTML-decode the JSON string to handle encoded characters like < -> <= + decodedJsStr := html.UnescapeString(jsStr) + if err := json.Unmarshal([]byte(decodedJsStr), &fc); err != nil { + logger.Error("failed to unmarshal tool call", "error", err, "json_string", decodedJsStr) + // Send error response to LLM so it can retry or handle the error + toolResponseMsg := models.RoleMsg{ + Role: cfg.ToolRole, + Content: fmt.Sprintf("Error processing tool call: %v. Please check the JSON format and try again.", err), + } + chatBody.Messages = append(chatBody.Messages, toolResponseMsg) + logger.Debug("findCall: added tool error response", "role", toolResponseMsg.Role, "content_len", len(toolResponseMsg.Content), "message_count_after_add", len(chatBody.Messages)) + // Trigger the assistant to continue processing with the error message + chatRound("", cfg.AssistantRole, tv, false, false) + return + } } // call a func f, ok := fnMap[fc.Name] if !ok { - m := fmt.Sprintf("%s is not implemented", fc.Name) - chatRound(m, toolRole, tv) + m := fc.Name + " is not implemented" + // Create tool response message with the proper tool_call_id + toolResponseMsg := models.RoleMsg{ + Role: cfg.ToolRole, + Content: m, + ToolCallID: lastToolCallID, // Use the stored tool call ID + } + chatBody.Messages = append(chatBody.Messages, toolResponseMsg) + logger.Debug("findCall: added tool not implemented response", "role", toolResponseMsg.Role, "content_len", len(toolResponseMsg.Content), "tool_call_id", toolResponseMsg.ToolCallID, "message_count_after_add", len(chatBody.Messages)) + // Clear the stored tool call ID after using it + lastToolCallID = "" + + // Trigger the assistant to continue processing with the new tool response + // by calling chatRound with empty content to continue the assistant's response + chatRound("", cfg.AssistantRole, tv, false, false) return } resp := f(fc.Args) - toolMsg := fmt.Sprintf("tool response: %+v", resp) - // reader := formMsg(chatBody, toolMsg, toolRole) - // sendMsgToLLM() - chatRound(toolMsg, toolRole, tv) - // return func result to the llm + toolMsg := string(resp) // Remove the "tool response: " prefix and %+v formatting + logger.Info("llm used tool call", "tool_resp", toolMsg, "tool_attrs", fc) + fmt.Fprintf(tv, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n", + "\n", len(chatBody.Messages), cfg.ToolRole, toolMsg) + // Create tool response message with the proper tool_call_id + toolResponseMsg := models.RoleMsg{ + Role: cfg.ToolRole, + Content: toolMsg, + ToolCallID: lastToolCallID, // Use the stored tool call ID + } + chatBody.Messages = append(chatBody.Messages, toolResponseMsg) + logger.Debug("findCall: added actual tool response", "role", toolResponseMsg.Role, "content_len", len(toolResponseMsg.Content), "tool_call_id", toolResponseMsg.ToolCallID, "message_count_after_add", len(chatBody.Messages)) + // Clear the stored tool call ID after using it + lastToolCallID = "" + // Trigger the assistant to continue processing with the new tool response + // by calling chatRound with empty content to continue the assistant's response + chatRound("", cfg.AssistantRole, tv, false, false) } func chatToTextSlice(showSys bool) []string { resp := make([]string, len(chatBody.Messages)) for i, msg := range chatBody.Messages { - if !showSys && (msg.Role != assistantRole && msg.Role != userRole) { + // INFO: skips system msg and tool msg + if !showSys && (msg.Role == cfg.ToolRole || msg.Role == "system") { continue } resp[i] = msg.ToText(i) @@ -203,50 +759,147 @@ func chatToText(showSys bool) string { return strings.Join(s, "") } -func textToMsg(rawMsg string) models.MessagesStory { - msg := models.MessagesStory{} - // system and tool? - if strings.HasPrefix(rawMsg, assistantIcon) { - msg.Role = assistantRole - msg.Content = strings.TrimPrefix(rawMsg, assistantIcon) - return msg +func removeThinking(chatBody *models.ChatBody) { + msgs := []models.RoleMsg{} + for _, msg := range chatBody.Messages { + // Filter out tool messages and thinking markers + if msg.Role == cfg.ToolRole { + continue + } + // find thinking and remove it + rm := models.RoleMsg{ + Role: msg.Role, + Content: thinkRE.ReplaceAllString(msg.Content, ""), + } + msgs = append(msgs, rm) } - if strings.HasPrefix(rawMsg, userIcon) { - msg.Role = userRole - msg.Content = strings.TrimPrefix(rawMsg, userIcon) - return msg + chatBody.Messages = msgs +} + +func addNewChat(chatName string) { + id, err := store.ChatGetMaxID() + if err != nil { + logger.Error("failed to get max chat id from db;", "id:", id) + // INFO: will rewrite first chat + } + chat := &models.Chat{ + ID: id + 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Agent: cfg.AssistantRole, + } + if chatName == "" { + chatName = fmt.Sprintf("%d_%s", chat.ID, cfg.AssistantRole) } - return msg + chat.Name = chatName + chatMap[chat.Name] = chat + activeChatName = chat.Name } -func textSliceToChat(chat []string) []models.MessagesStory { - resp := make([]models.MessagesStory, len(chat)) - for i, rawMsg := range chat { - msg := textToMsg(rawMsg) - resp[i] = msg +func applyCharCard(cc *models.CharCard) { + cfg.AssistantRole = cc.Role + // FIXME: remove + // Initialize Cluedo if enabled and matching role + if cfg.EnableCluedo && cc.Role == "CluedoPlayer" { + playerOrder = []string{cfg.UserRole, cfg.AssistantRole, cfg.CluedoRole2} + cluedoState = extra.CluedoPrepCards(playerOrder) } - return resp + history, err := loadAgentsLastChat(cfg.AssistantRole) + if err != nil { + // too much action for err != nil; loadAgentsLastChat needs to be split up + logger.Warn("failed to load last agent chat;", "agent", cc.Role, "err", err) + history = []models.RoleMsg{ + {Role: "system", Content: cc.SysPrompt}, + {Role: cfg.AssistantRole, Content: cc.FirstMsg}, + } + addNewChat("") + } + chatBody.Messages = history +} + +func charToStart(agentName string) bool { + cc, ok := sysMap[agentName] + if !ok { + return false + } + applyCharCard(cc) + return true } func init() { - file, err := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + var err error + cfg, err = config.LoadConfig("config.toml") + if err != nil { + fmt.Println("failed to load config.toml") + os.Exit(1) + return + } + defaultStarter = []models.RoleMsg{ + {Role: "system", Content: basicSysMsg}, + {Role: cfg.AssistantRole, Content: defaultFirstMsg}, + } + logfile, err := os.OpenFile(cfg.LogFile, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + slog.Error("failed to open log file", "error", err, "filename", cfg.LogFile) + return + } + defaultStarterBytes, err = json.Marshal(defaultStarter) if err != nil { - panic(err) + slog.Error("failed to marshal defaultStarter", "error", err) + return } - // create dir if does not exist - if err := os.MkdirAll(historyDir, os.ModePerm); err != nil { - panic(err) + // load cards + basicCard.Role = cfg.AssistantRole + // toolCard.Role = cfg.AssistantRole + // + logLevel.Set(slog.LevelInfo) + logger = slog.New(slog.NewTextHandler(logfile, &slog.HandlerOptions{Level: logLevel})) + store = storage.NewProviderSQL(cfg.DBPATH, logger) + if store == nil { + os.Exit(1) } - logger = slog.New(slog.NewTextHandler(file, nil)) - store = storage.NewProviderSQL("test.db", logger) + ragger = rag.New(logger, store, cfg) // https://github.com/coreydaley/ggerganov-llama.cpp/blob/master/examples/server/README.md // load all chats in memory - loadHistoryChats() + if _, err := loadHistoryChats(); err != nil { + logger.Error("failed to load chat", "error", err) + return + } + lastToolCall = &models.FuncCall{} lastChat := loadOldChatOrGetNew() - logger.Info("loaded history", "chat", lastChat) chatBody = &models.ChatBody{ - Model: "modl_name", + Model: "modelname", Stream: true, Messages: lastChat, } + // Initialize Cluedo if enabled and matching role + if cfg.EnableCluedo && cfg.AssistantRole == "CluedoPlayer" { + playerOrder = []string{cfg.UserRole, cfg.AssistantRole, cfg.CluedoRole2} + cluedoState = extra.CluedoPrepCards(playerOrder) + } + if cfg.OpenRouterToken != "" { + go func() { + ORModels, err := fetchORModels(true) + if err != nil { + logger.Error("failed to fetch or models", "error", err) + } else { + ORFreeModels = ORModels + } + }() + } + go func() { + LocalModels, err = fetchLCPModels() + if err != nil { + logger.Error("failed to fetch llama.cpp models", "error", err) + } + }() + choseChunkParser() + httpClient = createClient(time.Second * 15) + if cfg.TTS_ENABLED { + orator = extra.NewOrator(logger, cfg) + } + if cfg.STT_ENABLED { + asr = extra.NewSTT(logger, cfg) + } } |
