7 Commits

Author SHA1 Message Date
Grail Finder
ef53e9bebe Enha: json tag for stats 2026-02-23 09:35:40 +03:00
Grail Finder
a546bfe596 Enha: defer finalizeRespStats 2026-02-23 09:30:37 +03:00
Grail Finder
23c21f87bb Feat: show stats 2026-02-23 09:18:19 +03:00
Grail Finder
850ca103e5 Enha: update model status color 2026-02-22 19:06:45 +03:00
Grail Finder
b7b5fcbf79 Fix: tts skiping over sentences 2026-02-22 18:27:32 +03:00
Grail Finder
1e13c7796d Fix: character specific context tts 2026-02-22 17:45:09 +03:00
Grail Finder
9a727b21ad Enha: simplify status line 2026-02-22 17:27:24 +03:00
6 changed files with 116 additions and 77 deletions

65
bot.go
View File

@@ -46,6 +46,7 @@ var (
ragger *rag.RAG ragger *rag.RAG
chunkParser ChunkParser chunkParser ChunkParser
lastToolCall *models.FuncCall lastToolCall *models.FuncCall
lastRespStats *models.ResponseStats
//nolint:unused // TTS_ENABLED conditionally uses this //nolint:unused // TTS_ENABLED conditionally uses this
orator Orator orator Orator
asr STT asr STT
@@ -484,30 +485,28 @@ func monitorModelLoad(modelID string) {
// extractDetailedErrorFromBytes extracts detailed error information from response body bytes // extractDetailedErrorFromBytes extracts detailed error information from response body bytes
func extractDetailedErrorFromBytes(body []byte, statusCode int) string { func extractDetailedErrorFromBytes(body []byte, statusCode int) string {
// Try to parse as JSON to extract detailed error information // Try to parse as JSON to extract detailed error information
var errorResponse map[string]interface{} var errorResponse map[string]any
if err := json.Unmarshal(body, &errorResponse); err == nil { if err := json.Unmarshal(body, &errorResponse); err == nil {
// Check if it's an error response with detailed information // Check if it's an error response with detailed information
if errorData, ok := errorResponse["error"]; ok { if errorData, ok := errorResponse["error"]; ok {
if errorMap, ok := errorData.(map[string]interface{}); ok { if errorMap, ok := errorData.(map[string]any); ok {
var errorMsg string var errorMsg string
if msg, ok := errorMap["message"]; ok { if msg, ok := errorMap["message"]; ok {
errorMsg = fmt.Sprintf("%v", msg) errorMsg = fmt.Sprintf("%v", msg)
} }
var details []string var details []string
if code, ok := errorMap["code"]; ok { if code, ok := errorMap["code"]; ok {
details = append(details, fmt.Sprintf("Code: %v", code)) details = append(details, fmt.Sprintf("Code: %v", code))
} }
if metadata, ok := errorMap["metadata"]; ok { if metadata, ok := errorMap["metadata"]; ok {
// Handle metadata which might contain raw error details // Handle metadata which might contain raw error details
if metadataMap, ok := metadata.(map[string]interface{}); ok { if metadataMap, ok := metadata.(map[string]any); ok {
if raw, ok := metadataMap["raw"]; ok { if raw, ok := metadataMap["raw"]; ok {
// Parse the raw error string if it's JSON // Parse the raw error string if it's JSON
var rawError map[string]interface{} var rawError map[string]any
if rawStr, ok := raw.(string); ok && json.Unmarshal([]byte(rawStr), &rawError) == nil { if rawStr, ok := raw.(string); ok && json.Unmarshal([]byte(rawStr), &rawError) == nil {
if rawErrorData, ok := rawError["error"]; ok { if rawErrorData, ok := rawError["error"]; ok {
if rawErrorMap, ok := rawErrorData.(map[string]interface{}); ok { if rawErrorMap, ok := rawErrorData.(map[string]any); ok {
if rawMsg, ok := rawErrorMap["message"]; ok { if rawMsg, ok := rawErrorMap["message"]; ok {
return fmt.Sprintf("API Error: %s", rawMsg) return fmt.Sprintf("API Error: %s", rawMsg)
} }
@@ -518,20 +517,30 @@ func extractDetailedErrorFromBytes(body []byte, statusCode int) string {
} }
details = append(details, fmt.Sprintf("Metadata: %v", metadata)) details = append(details, fmt.Sprintf("Metadata: %v", metadata))
} }
if len(details) > 0 { if len(details) > 0 {
return fmt.Sprintf("API Error: %s (%s)", errorMsg, strings.Join(details, ", ")) return fmt.Sprintf("API Error: %s (%s)", errorMsg, strings.Join(details, ", "))
} }
return "API Error: " + errorMsg return "API Error: " + errorMsg
} }
} }
} }
// If not a structured error response, return the raw body with status // If not a structured error response, return the raw body with status
return fmt.Sprintf("HTTP Status: %d, Response Body: %s", statusCode, string(body)) return fmt.Sprintf("HTTP Status: %d, Response Body: %s", statusCode, string(body))
} }
func finalizeRespStats(tokenCount int, startTime time.Time) {
duration := time.Since(startTime).Seconds()
var tps float64
if duration > 0 {
tps = float64(tokenCount) / duration
}
lastRespStats = &models.ResponseStats{
Tokens: tokenCount,
Duration: duration,
TokensPerSec: tps,
}
}
// sendMsgToLLM expects streaming resp // sendMsgToLLM expects streaming resp
func sendMsgToLLM(body io.Reader) { func sendMsgToLLM(body io.Reader) {
choseChunkParser() choseChunkParser()
@@ -586,12 +595,17 @@ func sendMsgToLLM(body io.Reader) {
streamDone <- true streamDone <- true
return return
} }
//
defer resp.Body.Close() defer resp.Body.Close()
reader := bufio.NewReader(resp.Body) reader := bufio.NewReader(resp.Body)
counter := uint32(0) counter := uint32(0)
tokenCount := 0
startTime := time.Now()
hasReasoning := false hasReasoning := false
reasoningSent := false reasoningSent := false
defer func() {
finalizeRespStats(tokenCount, startTime)
}()
for { for {
var ( var (
answerText string answerText string
@@ -667,11 +681,13 @@ func sendMsgToLLM(body io.Reader) {
// Close the thinking block if we were streaming reasoning and haven't closed it yet // Close the thinking block if we were streaming reasoning and haven't closed it yet
if hasReasoning && !reasoningSent { if hasReasoning && !reasoningSent {
chunkChan <- "</think>" chunkChan <- "</think>"
tokenCount++
} }
if chunk.Chunk != "" { if chunk.Chunk != "" {
logger.Warn("text inside of finish llmchunk", "chunk", chunk, "counter", counter) logger.Warn("text inside of finish llmchunk", "chunk", chunk, "counter", counter)
answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n") answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n")
chunkChan <- answerText chunkChan <- answerText
tokenCount++
} }
streamDone <- true streamDone <- true
break break
@@ -684,12 +700,14 @@ func sendMsgToLLM(body io.Reader) {
if !hasReasoning { if !hasReasoning {
// First reasoning chunk - send opening tag // First reasoning chunk - send opening tag
chunkChan <- "<think>" chunkChan <- "<think>"
tokenCount++
hasReasoning = true hasReasoning = true
} }
// Stream reasoning content immediately // Stream reasoning content immediately
answerText = strings.ReplaceAll(chunk.Reasoning, "\n\n", "\n") answerText = strings.ReplaceAll(chunk.Reasoning, "\n\n", "\n")
if answerText != "" { if answerText != "" {
chunkChan <- answerText chunkChan <- answerText
tokenCount++
} }
} }
@@ -697,6 +715,7 @@ func sendMsgToLLM(body io.Reader) {
if chunk.Chunk != "" && hasReasoning && !reasoningSent { if chunk.Chunk != "" && hasReasoning && !reasoningSent {
// Close the thinking block before sending actual content // Close the thinking block before sending actual content
chunkChan <- "</think>" chunkChan <- "</think>"
tokenCount++
reasoningSent = true reasoningSent = true
} }
@@ -709,9 +728,11 @@ func sendMsgToLLM(body io.Reader) {
slices.Contains(stopStrings, answerText) { slices.Contains(stopStrings, answerText) {
logger.Debug("stop string detected on client side for completion endpoint", "stop_string", answerText) logger.Debug("stop string detected on client side for completion endpoint", "stop_string", answerText)
streamDone <- true streamDone <- true
break
} }
if answerText != "" { if answerText != "" {
chunkChan <- answerText chunkChan <- answerText
tokenCount++
} }
openAIToolChan <- chunk.ToolChunk openAIToolChan <- chunk.ToolChunk
if chunk.FuncName != "" { if chunk.FuncName != "" {
@@ -914,7 +935,6 @@ out:
textView.ScrollToEnd() textView.ScrollToEnd()
} }
case <-streamDone: case <-streamDone:
// drain any remaining chunks from chunkChan before exiting
for len(chunkChan) > 0 { for len(chunkChan) > 0 {
chunk := <-chunkChan chunk := <-chunkChan
fmt.Fprint(textView, chunk) fmt.Fprint(textView, chunk)
@@ -923,31 +943,40 @@ out:
textView.ScrollToEnd() textView.ScrollToEnd()
} }
if cfg.TTS_ENABLED { if cfg.TTS_ENABLED {
// Send chunk to audio stream handler
TTSTextChan <- chunk TTSTextChan <- chunk
} }
} }
if cfg.TTS_ENABLED { if cfg.TTS_ENABLED {
// msg is done; flush it down
TTSFlushChan <- true TTSFlushChan <- true
} }
break out break out
} }
} }
var msgStats *models.ResponseStats
if lastRespStats != nil {
msgStats = &models.ResponseStats{
Tokens: lastRespStats.Tokens,
Duration: lastRespStats.Duration,
TokensPerSec: lastRespStats.TokensPerSec,
}
lastRespStats = nil
}
botRespMode = false botRespMode = false
// numbers in chatbody and displayed must be the same
if r.Resume { if r.Resume {
chatBody.Messages[len(chatBody.Messages)-1].Content += respText.String() chatBody.Messages[len(chatBody.Messages)-1].Content += respText.String()
// lastM.Content = lastM.Content + respText.String()
// Process the updated message to check for known_to tags in resumed response
updatedMsg := chatBody.Messages[len(chatBody.Messages)-1] updatedMsg := chatBody.Messages[len(chatBody.Messages)-1]
processedMsg := processMessageTag(&updatedMsg) processedMsg := processMessageTag(&updatedMsg)
chatBody.Messages[len(chatBody.Messages)-1] = *processedMsg chatBody.Messages[len(chatBody.Messages)-1] = *processedMsg
if msgStats != nil && chatBody.Messages[len(chatBody.Messages)-1].Role != cfg.ToolRole {
chatBody.Messages[len(chatBody.Messages)-1].Stats = msgStats
}
} else { } else {
// Message was already added at the start, just process it for known_to tags
chatBody.Messages[msgIdx].Content = respText.String() chatBody.Messages[msgIdx].Content = respText.String()
processedMsg := processMessageTag(&chatBody.Messages[msgIdx]) processedMsg := processMessageTag(&chatBody.Messages[msgIdx])
chatBody.Messages[msgIdx] = *processedMsg chatBody.Messages[msgIdx] = *processedMsg
if msgStats != nil && chatBody.Messages[msgIdx].Role != cfg.ToolRole {
chatBody.Messages[msgIdx].Stats = msgStats
}
stopTTSIfNotForUser(&chatBody.Messages[msgIdx]) stopTTSIfNotForUser(&chatBody.Messages[msgIdx])
} }
cleanChatBody() cleanChatBody()

View File

@@ -92,8 +92,6 @@ func (o *KokoroOrator) stoproutine() {
func (o *KokoroOrator) readroutine() { func (o *KokoroOrator) readroutine() {
tokenizer, _ := english.NewSentenceTokenizer(nil) tokenizer, _ := english.NewSentenceTokenizer(nil)
// var sentenceBuf bytes.Buffer
// var remainder strings.Builder
for { for {
select { select {
case chunk := <-TTSTextChan: case chunk := <-TTSTextChan:
@@ -106,24 +104,28 @@ func (o *KokoroOrator) readroutine() {
continue continue
} }
text := o.textBuffer.String() text := o.textBuffer.String()
o.mu.Unlock()
sentences := tokenizer.Tokenize(text) sentences := tokenizer.Tokenize(text)
o.logger.Debug("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences)) o.logger.Debug("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences))
for i, sentence := range sentences { if len(sentences) <= 1 {
if i == len(sentences)-1 { // last sentence o.mu.Unlock()
o.mu.Lock() continue
o.textBuffer.Reset() }
_, err := o.textBuffer.WriteString(sentence.Text) completeSentences := sentences[:len(sentences)-1]
o.mu.Unlock() remaining := sentences[len(sentences)-1].Text
if err != nil { o.textBuffer.Reset()
o.logger.Warn("failed to write to stringbuilder", "error", err) o.textBuffer.WriteString(remaining)
continue o.mu.Unlock()
}
continue // if only one (often incomplete) sentence; wait for next chunk for _, sentence := range completeSentences {
o.mu.Lock()
interrupted := o.interrupt
o.mu.Unlock()
if interrupted {
return
} }
cleanedText := models.CleanText(sentence.Text) cleanedText := models.CleanText(sentence.Text)
if cleanedText == "" { if cleanedText == "" {
continue // Skip empty text after cleaning continue
} }
o.logger.Debug("calling Speak with sentence", "sent", cleanedText) o.logger.Debug("calling Speak with sentence", "sent", cleanedText)
if err := o.Speak(cleanedText); err != nil { if err := o.Speak(cleanedText); err != nil {
@@ -338,24 +340,28 @@ func (o *GoogleTranslateOrator) readroutine() {
continue continue
} }
text := o.textBuffer.String() text := o.textBuffer.String()
o.mu.Unlock()
sentences := tokenizer.Tokenize(text) sentences := tokenizer.Tokenize(text)
o.logger.Debug("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences)) o.logger.Debug("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences))
for i, sentence := range sentences { if len(sentences) <= 1 {
if i == len(sentences)-1 { // last sentence o.mu.Unlock()
o.mu.Lock() continue
o.textBuffer.Reset() }
_, err := o.textBuffer.WriteString(sentence.Text) completeSentences := sentences[:len(sentences)-1]
o.mu.Unlock() remaining := sentences[len(sentences)-1].Text
if err != nil { o.textBuffer.Reset()
o.logger.Warn("failed to write to stringbuilder", "error", err) o.textBuffer.WriteString(remaining)
continue o.mu.Unlock()
}
continue // if only one (often incomplete) sentence; wait for next chunk for _, sentence := range completeSentences {
o.mu.Lock()
interrupted := o.interrupt
o.mu.Unlock()
if interrupted {
return
} }
cleanedText := models.CleanText(sentence.Text) cleanedText := models.CleanText(sentence.Text)
if cleanedText == "" { if cleanedText == "" {
continue // Skip empty text after cleaning continue
} }
o.logger.Debug("calling Speak with sentence", "sent", cleanedText) o.logger.Debug("calling Speak with sentence", "sent", cleanedText)
if err := o.Speak(cleanedText); err != nil { if err := o.Speak(cleanedText); err != nil {

View File

@@ -108,14 +108,17 @@ func refreshChatDisplay() {
} }
} }
// stopTTSIfNotForUser: character specific context, not meant fot the human to hear
func stopTTSIfNotForUser(msg *models.RoleMsg) { func stopTTSIfNotForUser(msg *models.RoleMsg) {
if strings.Contains(cfg.CurrentAPI, "/chat") || !cfg.CharSpecificContextEnabled {
return
}
viewingAs := cfg.UserRole viewingAs := cfg.UserRole
if cfg.WriteNextMsgAs != "" { if cfg.WriteNextMsgAs != "" {
viewingAs = cfg.WriteNextMsgAs viewingAs = cfg.WriteNextMsgAs
} }
// stop tts if msg is not for user // stop tts if msg is not for user
if cfg.CharSpecificContextEnabled && if !slices.Contains(msg.KnownTo, viewingAs) && cfg.TTS_ENABLED {
!slices.Contains(msg.KnownTo, viewingAs) && cfg.TTS_ENABLED {
TTSDoneChan <- true TTSDoneChan <- true
} }
} }
@@ -354,13 +357,17 @@ func makeStatusLine() string {
} }
// Get model color based on load status for local llama.cpp models // Get model color based on load status for local llama.cpp models
modelColor := getModelColor() modelColor := getModelColor()
statusLine := fmt.Sprintf(statusLineTempl, boolColors[botRespMode], botRespMode, activeChatName, statusLine := fmt.Sprintf(statusLineTempl, boolColors[botRespMode], activeChatName,
boolColors[cfg.ToolUse], cfg.ToolUse, modelColor, chatBody.Model, boolColors[cfg.SkipLLMResp], boolColors[cfg.ToolUse], modelColor, chatBody.Model, boolColors[cfg.SkipLLMResp],
cfg.SkipLLMResp, cfg.CurrentAPI, boolColors[isRecording], isRecording, persona, cfg.CurrentAPI, persona, botPersona)
botPersona) if cfg.STT_ENABLED {
recordingS := fmt.Sprintf(" | [%s:-:b]voice recording[-:-:-] (ctrl+r)",
boolColors[isRecording])
statusLine += recordingS
}
// completion endpoint // completion endpoint
if !strings.Contains(cfg.CurrentAPI, "chat") { if !strings.Contains(cfg.CurrentAPI, "chat") {
roleInject := fmt.Sprintf(" | role injection [%s:-:b]%v[-:-:-] (alt+7)", boolColors[injectRole], injectRole) roleInject := fmt.Sprintf(" | [%s:-:b]role injection[-:-:-] (alt+7)", boolColors[injectRole])
statusLine += roleInject statusLine += roleInject
} }
return statusLine + imageInfo + shellModeInfo return statusLine + imageInfo + shellModeInfo

View File

@@ -13,7 +13,7 @@ var (
selectedIndex = int(-1) selectedIndex = int(-1)
shellMode = false shellMode = false
thinkingCollapsed = false thinkingCollapsed = false
statusLineTempl = "help (F12) | llm turn: [%s:-:b]%v[-:-:-] (F6) | chat: [orange:-:b]%s[-:-:-] (F1) |tool-use: [%s:-:b]%v[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [%s:-:b]%v[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | voice recording: [%s:-:b]%v[-:-:-] (ctrl+r) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)" statusLineTempl = "help (F12) | [%s:-:b]llm writes[-:-:-] (F6 to interrupt) | chat: [orange:-:b]%s[-:-:-] (F1) | [%s:-:b]tool use[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | [%s:-:b]skip LLM resp[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)"
focusSwitcher = map[tview.Primitive]tview.Primitive{} focusSwitcher = map[tview.Primitive]tview.Primitive{}
) )

View File

@@ -105,12 +105,13 @@ type ImageContentPart struct {
// RoleMsg represents a message with content that can be either a simple string or structured content parts // RoleMsg represents a message with content that can be either a simple string or structured content parts
type RoleMsg struct { type RoleMsg struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"-"` Content string `json:"-"`
ContentParts []any `json:"-"` ContentParts []any `json:"-"`
ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages
KnownTo []string `json:"known_to,omitempty"` KnownTo []string `json:"known_to,omitempty"`
hasContentParts bool // Flag to indicate which content type to marshal Stats *ResponseStats `json:"stats"`
hasContentParts bool // Flag to indicate which content type to marshal
} }
// MarshalJSON implements custom JSON marshaling for RoleMsg // MarshalJSON implements custom JSON marshaling for RoleMsg
@@ -183,13 +184,11 @@ func (m *RoleMsg) UnmarshalJSON(data []byte) error {
} }
func (m *RoleMsg) ToText(i int) string { func (m *RoleMsg) ToText(i int) string {
// Convert content to string representation
var contentStr string var contentStr string
var imageIndicators []string var imageIndicators []string
if !m.hasContentParts { if !m.hasContentParts {
contentStr = m.Content contentStr = m.Content
} else { } else {
// For structured content, collect text parts and image indicators
var textParts []string var textParts []string
for _, part := range m.ContentParts { for _, part := range m.ContentParts {
switch p := part.(type) { switch p := part.(type) {
@@ -198,7 +197,6 @@ func (m *RoleMsg) ToText(i int) string {
textParts = append(textParts, p.Text) textParts = append(textParts, p.Text)
} }
case ImageContentPart: case ImageContentPart:
// Collect image indicator
displayPath := p.Path displayPath := p.Path
if displayPath == "" { if displayPath == "" {
displayPath = "image" displayPath = "image"
@@ -216,7 +214,6 @@ func (m *RoleMsg) ToText(i int) string {
} }
} }
case "image_url": case "image_url":
// Handle unmarshaled image content
var displayPath string var displayPath string
if pathVal, pathExists := p["path"]; pathExists { if pathVal, pathExists := p["path"]; pathExists {
if pathStr, isStr := pathVal.(string); isStr && pathStr != "" { if pathStr, isStr := pathVal.(string); isStr && pathStr != "" {
@@ -233,23 +230,20 @@ func (m *RoleMsg) ToText(i int) string {
} }
contentStr = strings.Join(textParts, " ") + " " contentStr = strings.Join(textParts, " ") + " "
} }
// check if already has role annotation (/completion makes them)
// in that case remove it, and then add to icon
// since icon and content are separated by \n
contentStr, _ = strings.CutPrefix(contentStr, m.Role+":") contentStr, _ = strings.CutPrefix(contentStr, m.Role+":")
// if !strings.HasPrefix(contentStr, m.Role+":") {
icon := fmt.Sprintf("(%d) <%s>: ", i, m.Role) icon := fmt.Sprintf("(%d) <%s>: ", i, m.Role)
// }
// Build final message with image indicators before text
var finalContent strings.Builder var finalContent strings.Builder
if len(imageIndicators) > 0 { if len(imageIndicators) > 0 {
// Add each image indicator on its own line
for _, indicator := range imageIndicators { for _, indicator := range imageIndicators {
finalContent.WriteString(indicator) finalContent.WriteString(indicator)
finalContent.WriteString("\n") finalContent.WriteString("\n")
} }
} }
finalContent.WriteString(contentStr) finalContent.WriteString(contentStr)
if m.Stats != nil {
finalContent.WriteString(fmt.Sprintf("\n[gray::i][%d tok, %.1fs, %.1f t/s][-:-:-]",
m.Stats.Tokens, m.Stats.Duration, m.Stats.TokensPerSec))
}
textMsg := fmt.Sprintf("[-:-:b]%s[-:-:-]\n%s\n", icon, finalContent.String()) textMsg := fmt.Sprintf("[-:-:b]%s[-:-:-]\n%s\n", icon, finalContent.String())
return strings.ReplaceAll(textMsg, "\n\n", "\n") return strings.ReplaceAll(textMsg, "\n\n", "\n")
} }
@@ -331,6 +325,7 @@ func (m *RoleMsg) Copy() RoleMsg {
ContentParts: m.ContentParts, ContentParts: m.ContentParts,
ToolCallID: m.ToolCallID, ToolCallID: m.ToolCallID,
KnownTo: m.KnownTo, KnownTo: m.KnownTo,
Stats: m.Stats,
hasContentParts: m.hasContentParts, hasContentParts: m.hasContentParts,
} }
} }
@@ -643,6 +638,12 @@ func (lcp *LCPModels) ListModels() []string {
return resp return resp
} }
type ResponseStats struct {
Tokens int
Duration float64
TokensPerSec float64
}
type ChatRoundReq struct { type ChatRoundReq struct {
UserMsg string UserMsg string
Role string Role string

View File

@@ -61,14 +61,11 @@ func showModelSelectionPopup() {
modelListWidget.SetCurrentItem(currentModelIndex) modelListWidget.SetCurrentItem(currentModelIndex)
} }
modelListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { modelListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
// Strip "(loaded)" suffix if present for local llama.cpp models
modelName := strings.TrimPrefix(mainText, "(loaded) ") modelName := strings.TrimPrefix(mainText, "(loaded) ")
// Update the model in both chatBody and config
chatBody.Model = modelName chatBody.Model = modelName
cfg.CurrentModel = chatBody.Model cfg.CurrentModel = chatBody.Model
// Remove the popup page
pages.RemovePage("modelSelectionPopup") pages.RemovePage("modelSelectionPopup")
// Update the status line to reflect the change updateCachedModelColor()
updateStatusLine() updateStatusLine()
}) })
modelListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { modelListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
@@ -162,10 +159,9 @@ func showAPILinkSelectionPopup() {
chatBody.Model = newModelList[0] chatBody.Model = newModelList[0]
cfg.CurrentModel = chatBody.Model cfg.CurrentModel = chatBody.Model
} }
// Remove the popup page
pages.RemovePage("apiLinkSelectionPopup") pages.RemovePage("apiLinkSelectionPopup")
// Update the parser and status line to reflect the change
choseChunkParser() choseChunkParser()
updateCachedModelColor()
updateStatusLine() updateStatusLine()
}) })
apiListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { apiListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {