47 Commits

Author SHA1 Message Date
Grail Finder
d144ee76d9 Chore: pw tools to be disabled as default 2026-03-04 11:45:54 +03:00
Grail Finder
abcaad6609 Enha: native notification implementation 2026-03-04 11:25:13 +03:00
Grail Finder
50ce0200af Fix: graceful shutdown in tui, to avoid other key block 2026-03-04 08:29:47 +03:00
Grail Finder
58ccd63f4a Fix: avoid raw terminal after ctrl+c exit 2026-03-04 08:25:53 +03:00
Grail Finder
3611d7eb59 Fix: missfire of no-vision notification 2026-03-03 16:55:09 +03:00
Grail Finder
8974d2f52c Fix: remove panics from code 2026-03-03 14:51:36 +03:00
Grail Finder
6b0d03f2d6 Fix: decompres before notify 2026-03-03 14:26:06 +03:00
Grail Finder
fb4deb1161 Fix: handle empty choices 2026-03-03 14:13:18 +03:00
Grail Finder
0e5d37666f Enha: id for card map 2026-03-03 11:46:03 +03:00
Grail Finder
093103bdd7 Feat (pw_tools): click_at 2026-03-03 10:53:04 +03:00
Grail Finder
6c9a1ba56b Chore: change 'when askes' to more proactive phrasing 2026-03-03 09:37:34 +03:00
Grail Finder
93ecfc8a34 Enha: palywright dom and elements fetching 2026-03-03 09:27:05 +03:00
Grail Finder
0c9c590d8f Enha (playwright): conditionaly install and use tools 2026-03-03 09:15:18 +03:00
Grail Finder
d130254e88 Chore (pw): restructure 2026-03-03 08:35:18 +03:00
Grail Finder
6e7a063300 Enha: remove window tools if no vision 2026-03-03 08:27:14 +03:00
Grail Finder
c05b93299c Chore: linter complaints 2026-03-03 07:38:57 +03:00
Grail Finder
cad1bd46c1 Feat: playwright tools 2026-03-02 19:20:54 +03:00
Grail Finder
4bddce3700 Enha: compute estimate of non llm text 2026-03-02 15:21:45 +03:00
Grail Finder
fcc71987bf Feat: token use estimation 2026-03-02 14:54:20 +03:00
Grail Finder
8458edf5a8 Enha: interrupt llm and tool both 2026-03-02 12:19:50 +03:00
Grail Finder
07b06bb0d3 Enha: tabcompletion is back in textarea 2026-03-02 12:09:27 +03:00
Grail Finder
3389b1d83b Fix: linter complaints 2026-03-02 11:39:55 +03:00
Grail Finder
4f6000a43a Enha: check if model has vision before giving it vision tools 2026-03-02 11:25:20 +03:00
Grail Finder
9ba46b40cc Feat: screencapture for completion 2026-03-02 11:12:04 +03:00
Grail Finder
5bb456272e Feat: capture window (screenshot) 2026-03-02 10:33:41 +03:00
Grail Finder
8999f48fb9 Fix (completion): handle multiple images in history 2026-03-02 09:23:22 +03:00
Grail Finder
b2f280a7f1 Feat: read img for completion 2026-03-02 07:46:08 +03:00
Grail Finder
65cbd5d6a6 Fix (ctrl+v): trim loaded mark from the model 2026-03-02 07:19:21 +03:00
Grail Finder
caac1d397a Feat: read img tool for chat endpoint 2026-03-02 07:12:28 +03:00
Grail Finder
742f1ca838 Enha: modal affirmation popup on sending empty msg 2026-03-01 16:21:18 +03:00
Grail Finder
e36bade353 Fix: escape with empty textarea not generating response 2026-03-01 13:33:25 +03:00
Grail Finder
01d8bcdbf5 Enha: avoid \n\n in tool collapse 2026-03-01 12:28:23 +03:00
Grail Finder
f6a395bce9 Fix: todo_update 2026-03-01 12:16:17 +03:00
Grail Finder
dc34c63256 Feat: handle llm's cd use 2026-03-01 11:44:43 +03:00
Grail Finder
cdfccf9a24 Enha (llama.cpp): show loaded model on startup 2026-03-01 08:22:02 +03:00
Grail Finder
1f112259d2 Enha(tools.todo): always provide whole todo list 2026-03-01 07:01:13 +03:00
Grail Finder
a505ffaaa9 Fix (tool): handle subcommands 2026-02-28 16:16:32 +03:00
Grail Finder
32be271aa3 Feat (tools): file_edit 2026-02-28 15:40:52 +03:00
Grail Finder
133ec27938 Feat(shell): cd and pipes support 2026-02-28 13:59:54 +03:00
Grail Finder
d79760a289 Fix: do not delete tool calls or lose them on copy 2026-02-28 10:23:03 +03:00
Grail Finder
2580360f91 Fix: removed code that deletes tool calls 2026-02-28 09:13:05 +03:00
Grail Finder
fe4dd0c982 Enha: add go to allowed commands 2026-02-28 08:39:13 +03:00
Grail Finder
83f99d3577 Enha: first chat name convention 2026-02-28 08:09:56 +03:00
Grail Finder
e521434073 Refactor: move msg totext method to main package
logic requires reference to config
2026-02-28 07:57:49 +03:00
Grail Finder
916c5d3904 Enha: icon for collapsed tools 2026-02-27 21:25:26 +03:00
Grail Finder
5b1cbb46fa Chore: linter complaints 2026-02-27 20:03:47 +03:00
Grail Finder
1fcab8365e Enha: tool filter 2026-02-27 18:45:59 +03:00
28 changed files with 2730 additions and 977 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: setconfig run lint install-linters setup-whisper build-whisper download-whisper-model docker-up docker-down docker-logs noextra-run installdelve checkdelve .PHONY: setconfig run lint lintall install-linters setup-whisper build-whisper download-whisper-model docker-up docker-down docker-logs noextra-run installdelve checkdelve
run: setconfig run: setconfig
go build -tags extra -o gf-lt && ./gf-lt go build -tags extra -o gf-lt && ./gf-lt
@@ -25,7 +25,10 @@ install-linters: ## Install additional linters (noblanks)
go install github.com/GrailFinder/noblanks-linter/cmd/noblanks@latest go install github.com/GrailFinder/noblanks-linter/cmd/noblanks@latest
lint: ## Run linters. Use make install-linters first. lint: ## Run linters. Use make install-linters first.
golangci-lint run -c .golangci.yml ./...; noblanks ./... golangci-lint run -c .golangci.yml ./...
lintall: lint
noblanks ./...
# Whisper STT Setup (in batteries directory) # Whisper STT Setup (in batteries directory)
setup-whisper: build-whisper download-whisper-model setup-whisper: build-whisper download-whisper-model

View File

@@ -71,8 +71,8 @@ func (ag *AgentClient) buildRequest(sysprompt, msg string) ([]byte, error) {
// Build prompt for completion endpoints // Build prompt for completion endpoints
if isCompletion { if isCompletion {
var sb strings.Builder var sb strings.Builder
for _, m := range messages { for i := range messages {
sb.WriteString(m.ToPrompt()) sb.WriteString(messages[i].ToPrompt())
sb.WriteString("\n") sb.WriteString("\n")
} }
prompt := strings.TrimSpace(sb.String()) prompt := strings.TrimSpace(sb.String())

446
bot.go
View File

@@ -3,6 +3,7 @@ package main
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"compress/gzip"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -63,9 +64,13 @@ var (
"google/gemma-3-27b-it:free", "google/gemma-3-27b-it:free",
"meta-llama/llama-3.3-70b-instruct:free", "meta-llama/llama-3.3-70b-instruct:free",
} }
LocalModels = []string{} LocalModels = []string{}
localModelsData *models.LCPModels
orModelsData *models.ORModels
) )
var thinkBlockRE = regexp.MustCompile(`(?s)<think>.*?</think>`)
// parseKnownToTag extracts known_to list from content using configured tag. // parseKnownToTag extracts known_to list from content using configured tag.
// Returns cleaned content and list of character names. // Returns cleaned content and list of character names.
func parseKnownToTag(content string) []string { func parseKnownToTag(content string) []string {
@@ -136,6 +141,9 @@ func processMessageTag(msg *models.RoleMsg) *models.RoleMsg {
// filterMessagesForCharacter returns messages visible to the specified character. // filterMessagesForCharacter returns messages visible to the specified character.
// If CharSpecificContextEnabled is false, returns all messages. // If CharSpecificContextEnabled is false, returns all messages.
func filterMessagesForCharacter(messages []models.RoleMsg, character string) []models.RoleMsg { func filterMessagesForCharacter(messages []models.RoleMsg, character string) []models.RoleMsg {
if strings.Contains(cfg.CurrentAPI, "chat") {
return messages
}
if cfg == nil || !cfg.CharSpecificContextEnabled || character == "" { if cfg == nil || !cfg.CharSpecificContextEnabled || character == "" {
return messages return messages
} }
@@ -143,97 +151,67 @@ func filterMessagesForCharacter(messages []models.RoleMsg, character string) []m
return messages return messages
} }
filtered := make([]models.RoleMsg, 0, len(messages)) filtered := make([]models.RoleMsg, 0, len(messages))
for _, msg := range messages { for i := range messages {
// If KnownTo is nil or empty, message is visible to all // If KnownTo is nil or empty, message is visible to all
// system msg cannot be filtered // system msg cannot be filtered
if len(msg.KnownTo) == 0 || msg.Role == "system" { if len(messages[i].KnownTo) == 0 || messages[i].Role == "system" {
filtered = append(filtered, msg) filtered = append(filtered, messages[i])
continue continue
} }
if slices.Contains(msg.KnownTo, character) { if slices.Contains(messages[i].KnownTo, character) {
// Check if character is in KnownTo lis // Check if character is in KnownTo lis
filtered = append(filtered, msg) filtered = append(filtered, messages[i])
} }
} }
return filtered return filtered
} }
func cleanToolCalls(messages []models.RoleMsg) []models.RoleMsg {
// If AutoCleanToolCallsFromCtx is false, keep tool call messages in context
if cfg != nil && !cfg.AutoCleanToolCallsFromCtx {
return consolidateAssistantMessages(messages)
}
cleaned := make([]models.RoleMsg, 0, len(messages))
for i, msg := range messages {
// recognize the message as the tool call and remove it
// tool call in last msg should stay
if msg.ToolCallID == "" || i == len(messages)-1 {
cleaned = append(cleaned, msg)
}
}
return consolidateAssistantMessages(cleaned)
}
// consolidateAssistantMessages merges consecutive assistant messages into a single message
func consolidateAssistantMessages(messages []models.RoleMsg) []models.RoleMsg { func consolidateAssistantMessages(messages []models.RoleMsg) []models.RoleMsg {
if len(messages) == 0 { if len(messages) == 0 {
return messages return messages
} }
consolidated := make([]models.RoleMsg, 0, len(messages)) result := make([]models.RoleMsg, 0, len(messages))
currentAssistantMsg := models.RoleMsg{} for i := range messages {
isBuildingAssistantMsg := false // Non-assistant messages are appended as-is
for i := 0; i < len(messages); i++ { if messages[i].Role != cfg.AssistantRole {
msg := messages[i] result = append(result, messages[i])
// assistant role only continue
if msg.Role == cfg.AssistantRole { }
// If this is an assistant message, start or continue building // Assistant message: start a new block or merge with the last one
if !isBuildingAssistantMsg { if len(result) == 0 || result[len(result)-1].Role != cfg.AssistantRole {
// Start accumulating assistant message // First assistant in a block: append a copy (avoid mutating input)
currentAssistantMsg = msg.Copy() result = append(result, messages[i].Copy())
isBuildingAssistantMsg = true continue
} else { }
// Continue accumulating - append content to the current assistant message // Merge with the last assistant message
if currentAssistantMsg.IsContentParts() || msg.IsContentParts() { last := &result[len(result)-1]
// Handle structured content // If either message has structured content, unify to ContentParts
if !currentAssistantMsg.IsContentParts() { if last.IsContentParts() || messages[i].IsContentParts() {
// Preserve the original ToolCallID before conversion // Convert last to ContentParts if needed, preserving ToolCallID
originalToolCallID := currentAssistantMsg.ToolCallID if !last.IsContentParts() {
// Convert existing content to content parts toolCallID := last.ToolCallID
currentAssistantMsg = models.NewMultimodalMsg(currentAssistantMsg.Role, []interface{}{models.TextContentPart{Type: "text", Text: currentAssistantMsg.Content}}) *last = models.NewMultimodalMsg(last.Role, []interface{}{
// Restore the original ToolCallID to preserve tool call linking models.TextContentPart{Type: "text", Text: last.Content},
currentAssistantMsg.ToolCallID = originalToolCallID })
} last.ToolCallID = toolCallID
if msg.IsContentParts() { }
currentAssistantMsg.ContentParts = append(currentAssistantMsg.ContentParts, msg.GetContentParts()...) // Add current message's content to last
} else if msg.Content != "" { if messages[i].IsContentParts() {
currentAssistantMsg.AddTextPart(msg.Content) last.ContentParts = append(last.ContentParts, messages[i].GetContentParts()...)
} } else if messages[i].Content != "" {
} else { last.AddTextPart(messages[i].Content)
// 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 { } else {
// This is not an assistant message // Both simple strings: concatenate with newline
// If we were building an assistant message, add it to the result if last.Content != "" && messages[i].Content != "" {
if isBuildingAssistantMsg { last.Content += "\n" + messages[i].Content
consolidated = append(consolidated, currentAssistantMsg) } else if messages[i].Content != "" {
isBuildingAssistantMsg = false last.Content = messages[i].Content
} }
// Add the non-assistant message // ToolCallID is already preserved in last
consolidated = append(consolidated, msg)
} }
} }
// Don't forget the last assistant message if we were building one return result
if isBuildingAssistantMsg {
consolidated = append(consolidated, currentAssistantMsg)
}
return consolidated
} }
// GetLogLevel returns the current log level as a string // GetLogLevel returns the current log level as a string
@@ -290,9 +268,7 @@ func warmUpModel() {
// Continue with warmup attempt anyway // Continue with warmup attempt anyway
} }
if loaded { if loaded {
if err := notifyUser("model already loaded", "Model "+chatBody.Model+" is already loaded."); err != nil { showToast("model already loaded", "Model "+chatBody.Model+" is already loaded.")
logger.Debug("failed to notify user", "error", err)
}
return return
} }
go func() { go func() {
@@ -380,6 +356,7 @@ func fetchORModels(free bool) ([]string, error) {
if err := json.NewDecoder(resp.Body).Decode(data); err != nil { if err := json.NewDecoder(resp.Body).Decode(data); err != nil {
return nil, err return nil, err
} }
orModelsData = data
freeModels := data.ListModels(free) freeModels := data.ListModels(free)
return freeModels, nil return freeModels, nil
} }
@@ -404,22 +381,22 @@ func fetchLCPModels() ([]string, error) {
// fetchLCPModelsWithLoadStatus returns models with "(loaded)" indicator for loaded models // fetchLCPModelsWithLoadStatus returns models with "(loaded)" indicator for loaded models
func fetchLCPModelsWithLoadStatus() ([]string, error) { func fetchLCPModelsWithLoadStatus() ([]string, error) {
models, err := fetchLCPModelsWithStatus() modelList, err := fetchLCPModelsWithStatus()
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := make([]string, 0, len(models.Data)) result := make([]string, 0, len(modelList.Data))
li := 0 // loaded index li := 0 // loaded index
for i, m := range models.Data { for i, m := range modelList.Data {
modelName := m.ID modelName := m.ID
if m.Status.Value == "loaded" { if m.Status.Value == "loaded" {
modelName = "(loaded) " + modelName modelName = models.LoadedMark + modelName
li = i li = i
} }
result = append(result, modelName) result = append(result, modelName)
} }
if li == 0 { if li == 0 {
return result, nil // no loaded models return result, nil // no loaded modelList
} }
loadedModel := result[li] loadedModel := result[li]
result = append(result[:li], result[li+1:]...) result = append(result[:li], result[li+1:]...)
@@ -441,6 +418,7 @@ func fetchLCPModelsWithStatus() (*models.LCPModels, error) {
if err := json.NewDecoder(resp.Body).Decode(data); err != nil { if err := json.NewDecoder(resp.Body).Decode(data); err != nil {
return nil, err return nil, err
} }
localModelsData = data
return data, nil return data, nil
} }
@@ -458,6 +436,33 @@ func isModelLoaded(modelID string) (bool, error) {
return false, nil return false, nil
} }
func ModelHasVision(api, modelID string) bool {
switch {
case strings.Contains(api, "deepseek"):
return false
case strings.Contains(api, "openrouter"):
resp, err := http.Get("https://openrouter.ai/api/v1/models")
if err != nil {
logger.Warn("failed to fetch OR models for vision check", "error", err)
return false
}
defer resp.Body.Close()
orm := &models.ORModels{}
if err := json.NewDecoder(resp.Body).Decode(orm); err != nil {
logger.Warn("failed to decode OR models for vision check", "error", err)
return false
}
return orm.HasVision(modelID)
default:
models, err := fetchLCPModelsWithStatus()
if err != nil {
logger.Warn("failed to fetch LCP models for vision check", "error", err)
return false
}
return models.HasVision(modelID)
}
}
// monitorModelLoad starts a goroutine that periodically checks if the specified model is loaded. // monitorModelLoad starts a goroutine that periodically checks if the specified model is loaded.
func monitorModelLoad(modelID string) { func monitorModelLoad(modelID string) {
go func() { go func() {
@@ -476,9 +481,7 @@ func monitorModelLoad(modelID string) {
continue continue
} }
if loaded { if loaded {
if err := notifyUser("model loaded", "Model "+modelID+" is now loaded and ready."); err != nil { showToast("model loaded", "Model "+modelID+" is now loaded and ready.")
logger.Debug("failed to notify user", "error", err)
}
refreshChatDisplay() refreshChatDisplay()
return return
} }
@@ -489,6 +492,17 @@ 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 decompress gzip if the response is compressed
if len(body) >= 2 && body[0] == 0x1f && body[1] == 0x8b {
reader, err := gzip.NewReader(bytes.NewReader(body))
if err == nil {
decompressed, err := io.ReadAll(reader)
reader.Close()
if err == nil {
body = decompressed
}
}
}
// Try to parse as JSON to extract detailed error information // Try to parse as JSON to extract detailed error information
var errorResponse map[string]any var errorResponse map[string]any
if err := json.Unmarshal(body, &errorResponse); err == nil { if err := json.Unmarshal(body, &errorResponse); err == nil {
@@ -554,9 +568,7 @@ func sendMsgToLLM(body io.Reader) {
req, err := http.NewRequest("POST", cfg.CurrentAPI, body) req, err := http.NewRequest("POST", cfg.CurrentAPI, body)
if err != nil { if err != nil {
logger.Error("newreq error", "error", err) logger.Error("newreq error", "error", err)
if err := notifyUser("error", "apicall failed:"+err.Error()); err != nil { showToast("error", "apicall failed:"+err.Error())
logger.Error("failed to notify", "error", err)
}
streamDone <- true streamDone <- true
return return
} }
@@ -568,9 +580,7 @@ func sendMsgToLLM(body io.Reader) {
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
logger.Error("llamacpp api", "error", err) logger.Error("llamacpp api", "error", err)
if err := notifyUser("error", "apicall failed:"+err.Error()); err != nil { showToast("error", "apicall failed:"+err.Error())
logger.Error("failed to notify", "error", err)
}
streamDone <- true streamDone <- true
return return
} }
@@ -581,9 +591,7 @@ func sendMsgToLLM(body io.Reader) {
if err != nil { if err != nil {
logger.Error("failed to read error response body", "error", err, "status_code", resp.StatusCode) logger.Error("failed to read error response body", "error", err, "status_code", resp.StatusCode)
detailedError := fmt.Sprintf("HTTP Status: %d, Failed to read response body: %v", resp.StatusCode, err) detailedError := fmt.Sprintf("HTTP Status: %d, Failed to read response body: %v", resp.StatusCode, err)
if err := notifyUser("API Error", detailedError); err != nil { showToast("API Error", detailedError)
logger.Error("failed to notify", "error", err)
}
resp.Body.Close() resp.Body.Close()
streamDone <- true streamDone <- true
return return
@@ -591,9 +599,7 @@ func sendMsgToLLM(body io.Reader) {
// Parse the error response for detailed information // Parse the error response for detailed information
detailedError := extractDetailedErrorFromBytes(bodyBytes, resp.StatusCode) detailedError := extractDetailedErrorFromBytes(bodyBytes, resp.StatusCode)
logger.Error("API returned error status", "status_code", resp.StatusCode, "detailed_error", detailedError) logger.Error("API returned error status", "status_code", resp.StatusCode, "detailed_error", detailedError)
if err := notifyUser("API Error", detailedError); err != nil { showToast("API Error", detailedError)
logger.Error("failed to notify", "error", err)
}
resp.Body.Close() resp.Body.Close()
streamDone <- true streamDone <- true
return return
@@ -630,16 +636,12 @@ func sendMsgToLLM(body io.Reader) {
detailedError := fmt.Sprintf("Streaming connection closed unexpectedly (Status: %d). This may indicate an API error. Check your API provider and model settings.", resp.StatusCode) detailedError := fmt.Sprintf("Streaming connection closed unexpectedly (Status: %d). This may indicate an API error. Check your API provider and model settings.", resp.StatusCode)
logger.Error("error reading response body", "error", err, "detailed_error", detailedError, logger.Error("error reading response body", "error", err, "detailed_error", detailedError,
"status_code", resp.StatusCode, "user_role", cfg.UserRole, "parser", chunkParser, "link", cfg.CurrentAPI) "status_code", resp.StatusCode, "user_role", cfg.UserRole, "parser", chunkParser, "link", cfg.CurrentAPI)
if err := notifyUser("API Error", detailedError); err != nil { showToast("API Error", detailedError)
logger.Error("failed to notify", "error", err)
}
} else { } else {
logger.Error("error reading response body", "error", err, "line", string(line), logger.Error("error reading response body", "error", err, "line", string(line),
"user_role", cfg.UserRole, "parser", chunkParser, "link", cfg.CurrentAPI) "user_role", cfg.UserRole, "parser", chunkParser, "link", cfg.CurrentAPI)
// if err.Error() != "EOF" { // if err.Error() != "EOF" {
if err := notifyUser("API error", err.Error()); err != nil { showToast("API error", err.Error())
logger.Error("failed to notify", "error", err)
}
} }
streamDone <- true streamDone <- true
break break
@@ -666,9 +668,7 @@ func sendMsgToLLM(body io.Reader) {
if err != nil { if err != nil {
logger.Error("error parsing response body", "error", err, logger.Error("error parsing response body", "error", err,
"line", string(line), "url", cfg.CurrentAPI) "line", string(line), "url", cfg.CurrentAPI)
if err := notifyUser("LLM Response Error", "Failed to parse LLM response: "+err.Error()); err != nil { showToast("LLM Response Error", "Failed to parse LLM response: "+err.Error())
logger.Error("failed to notify user", "error", err)
}
streamDone <- true streamDone <- true
break break
} }
@@ -743,7 +743,7 @@ func sendMsgToLLM(body io.Reader) {
} }
interrupt: interrupt:
if interruptResp { // read bytes, so it would not get into beginning of the next req if interruptResp { // read bytes, so it would not get into beginning of the next req
interruptResp = false // interruptResp = false
logger.Info("interrupted bot response", "chunk_counter", counter) logger.Info("interrupted bot response", "chunk_counter", counter)
streamDone <- true streamDone <- true
break break
@@ -777,7 +777,7 @@ func showSpinner() {
botPersona = cfg.WriteNextMsgAsCompletionAgent botPersona = cfg.WriteNextMsgAsCompletionAgent
} }
for botRespMode || toolRunningMode { for botRespMode || toolRunningMode {
time.Sleep(100 * time.Millisecond) time.Sleep(400 * time.Millisecond)
spin := i % len(spinners) spin := i % len(spinners)
app.QueueUpdateDraw(func() { app.QueueUpdateDraw(func() {
switch { switch {
@@ -797,6 +797,7 @@ func showSpinner() {
} }
func chatRound(r *models.ChatRoundReq) error { func chatRound(r *models.ChatRoundReq) error {
interruptResp = false
botRespMode = true botRespMode = true
go showSpinner() go showSpinner()
updateStatusLine() updateStatusLine()
@@ -960,7 +961,12 @@ out:
if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil { if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil {
logger.Warn("failed to update storage", "error", err, "name", activeChatName) logger.Warn("failed to update storage", "error", err, "name", activeChatName)
} }
if findCall(respText.String(), toolResp.String()) { // Strip think blocks before parsing for tool calls
respTextNoThink := thinkBlockRE.ReplaceAllString(respText.String(), "")
if interruptResp {
return nil
}
if findCall(respTextNoThink, toolResp.String()) {
return nil return nil
} }
// Check if this message was sent privately to specific characters // Check if this message was sent privately to specific characters
@@ -982,7 +988,7 @@ func cleanChatBody() {
} }
// Tool request cleaning is now configurable via AutoCleanToolCallsFromCtx (default false) // Tool request cleaning is now configurable via AutoCleanToolCallsFromCtx (default false)
// /completion msg where part meant for user and other part tool call // /completion msg where part meant for user and other part tool call
chatBody.Messages = cleanToolCalls(chatBody.Messages) // chatBody.Messages = cleanToolCalls(chatBody.Messages)
chatBody.Messages = consolidateAssistantMessages(chatBody.Messages) chatBody.Messages = consolidateAssistantMessages(chatBody.Messages)
} }
@@ -1096,22 +1102,38 @@ func findCall(msg, toolCall string) bool {
} }
lastToolCall.Args = openAIToolMap lastToolCall.Args = openAIToolMap
fc = lastToolCall fc = lastToolCall
// Set lastToolCall.ID from parsed tool call ID if available // NOTE: We do NOT override lastToolCall.ID from arguments.
if len(openAIToolMap) > 0 { // The ID should come from the streaming response (chunk.ToolID) set earlier.
if id, exists := openAIToolMap["id"]; exists { // Some tools like todo_create have "id" in their arguments which is NOT the tool call ID.
lastToolCall.ID = id
}
}
} else { } else {
jsStr := toolCallRE.FindString(msg) jsStr := toolCallRE.FindString(msg)
if jsStr == "" { // no tool call case if jsStr == "" { // no tool call case
return false return false
} }
prefix := "__tool_call__\n" // Remove prefix/suffix with flexible whitespace handling
suffix := "\n__tool_call__" jsStr = strings.TrimSpace(jsStr)
jsStr = strings.TrimSuffix(strings.TrimPrefix(jsStr, prefix), suffix) jsStr = strings.TrimPrefix(jsStr, "__tool_call__")
jsStr = strings.TrimSuffix(jsStr, "__tool_call__")
jsStr = strings.TrimSpace(jsStr)
// HTML-decode the JSON string to handle encoded characters like &lt; -> <= // HTML-decode the JSON string to handle encoded characters like &lt; -> <=
decodedJsStr := html.UnescapeString(jsStr) decodedJsStr := html.UnescapeString(jsStr)
// Try to find valid JSON bounds (first { to last })
start := strings.Index(decodedJsStr, "{")
end := strings.LastIndex(decodedJsStr, "}")
if start == -1 || end == -1 || end <= start {
logger.Error("failed to find valid JSON in tool call", "json_string", decodedJsStr)
toolResponseMsg := models.RoleMsg{
Role: cfg.ToolRole,
Content: "Error processing tool call: no valid JSON found. Please check the JSON format.",
}
chatBody.Messages = append(chatBody.Messages, toolResponseMsg)
crr := &models.ChatRoundReq{
Role: cfg.AssistantRole,
}
chatRoundChan <- crr
return true
}
decodedJsStr = decodedJsStr[start : end+1]
var err error var err error
fc, err = unmarshalFuncCall(decodedJsStr) fc, err = unmarshalFuncCall(decodedJsStr)
if err != nil { if err != nil {
@@ -1138,14 +1160,18 @@ func findCall(msg, toolCall string) bool {
lastToolCall.Args = fc.Args lastToolCall.Args = fc.Args
} }
// we got here => last msg recognized as a tool call (correct or not) // we got here => last msg recognized as a tool call (correct or not)
// make sure it has ToolCallID // Use the tool call ID from streaming response (lastToolCall.ID)
if chatBody.Messages[len(chatBody.Messages)-1].ToolCallID == "" { // Don't generate random ID - the ID should match between assistant message and tool response
// Tool call IDs should be alphanumeric strings with length 9! lastMsgIdx := len(chatBody.Messages) - 1
chatBody.Messages[len(chatBody.Messages)-1].ToolCallID = randString(9) if lastToolCall.ID != "" {
chatBody.Messages[lastMsgIdx].ToolCallID = lastToolCall.ID
} }
// Ensure lastToolCall.ID is set, fallback to assistant message's ToolCallID // Store tool call info in the assistant message
if lastToolCall.ID == "" { // Convert Args map to JSON string for storage
lastToolCall.ID = chatBody.Messages[len(chatBody.Messages)-1].ToolCallID chatBody.Messages[lastMsgIdx].ToolCall = &models.ToolCall{
ID: lastToolCall.ID,
Name: lastToolCall.Name,
Args: mapToString(lastToolCall.Args),
} }
// call a func // call a func
_, ok := fnMap[fc.Name] _, ok := fnMap[fc.Name]
@@ -1175,16 +1201,61 @@ func findCall(msg, toolCall string) bool {
toolRunningMode = true toolRunningMode = true
resp := callToolWithAgent(fc.Name, fc.Args) resp := callToolWithAgent(fc.Name, fc.Args)
toolRunningMode = false toolRunningMode = false
toolMsg := string(resp) // Remove the "tool response: " prefix and %+v formatting toolMsg := string(resp)
logger.Info("llm used a tool call", "tool_name", fc.Name, "too_args", fc.Args, "id", fc.ID, "tool_resp", toolMsg) logger.Info("llm used a tool call", "tool_name", fc.Name, "too_args", fc.Args, "id", fc.ID, "tool_resp", toolMsg)
fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
"\n\n", len(chatBody.Messages), cfg.ToolRole, toolMsg)
// Create tool response message with the proper tool_call_id // Create tool response message with the proper tool_call_id
toolResponseMsg := models.RoleMsg{ // Mark shell commands as always visible
Role: cfg.ToolRole, isShellCommand := fc.Name == "execute_command"
Content: toolMsg, // Check if response is multimodal content (image)
ToolCallID: lastToolCall.ID, // Use the stored tool call ID var toolResponseMsg models.RoleMsg
if strings.HasPrefix(strings.TrimSpace(toolMsg), `{"type":"multimodal_content"`) {
// Parse multimodal content response
multimodalResp := models.MultimodalToolResp{}
if err := json.Unmarshal([]byte(toolMsg), &multimodalResp); err == nil && multimodalResp.Type == "multimodal_content" {
// Create RoleMsg with ContentParts
var contentParts []any
for _, part := range multimodalResp.Parts {
partType := part["type"]
switch partType {
case "text":
contentParts = append(contentParts, models.TextContentPart{Type: "text", Text: part["text"]})
case "image_url":
contentParts = append(contentParts, models.ImageContentPart{
Type: "image_url",
ImageURL: struct {
URL string `json:"url"`
}{URL: part["url"]},
})
default:
continue
}
}
toolResponseMsg = models.RoleMsg{
Role: cfg.ToolRole,
ContentParts: contentParts,
HasContentParts: true,
ToolCallID: lastToolCall.ID,
IsShellCommand: isShellCommand,
}
} else {
// Fallback to regular content
toolResponseMsg = models.RoleMsg{
Role: cfg.ToolRole,
Content: toolMsg,
ToolCallID: lastToolCall.ID,
IsShellCommand: isShellCommand,
}
}
} else {
toolResponseMsg = models.RoleMsg{
Role: cfg.ToolRole,
Content: toolMsg,
ToolCallID: lastToolCall.ID,
IsShellCommand: isShellCommand,
}
} }
fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
"\n\n", len(chatBody.Messages), cfg.ToolRole, toolResponseMsg.GetText())
chatBody.Messages = append(chatBody.Messages, toolResponseMsg) 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)) 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 // Clear the stored tool call ID after using it
@@ -1200,12 +1271,42 @@ func findCall(msg, toolCall string) bool {
func chatToTextSlice(messages []models.RoleMsg, showSys bool) []string { func chatToTextSlice(messages []models.RoleMsg, showSys bool) []string {
resp := make([]string, len(messages)) resp := make([]string, len(messages))
for i, msg := range messages { for i := range messages {
// INFO: skips system msg and tool msg icon := fmt.Sprintf("[-:-:b](%d) <%s>:[-:-:-]", i, messages[i].Role)
if !showSys && (msg.Role == cfg.ToolRole || msg.Role == "system") { // Handle tool call indicators (assistant messages with tool call but empty content)
if messages[i].Role == cfg.AssistantRole && messages[i].ToolCall != nil && messages[i].ToolCall.ID != "" {
// This is a tool call indicator - show collapsed
if toolCollapsed {
toolName := messages[i].ToolCall.Name
resp[i] = strings.ReplaceAll(fmt.Sprintf("%s\n%s\n[yellow::i][tool call: %s (press Ctrl+T to expand)][-:-:-]\n", icon, messages[i].GetText(), toolName), "\n\n", "\n")
} else {
// Show full tool call info
toolName := messages[i].ToolCall.Name
resp[i] = strings.ReplaceAll(fmt.Sprintf("%s\n%s\n[yellow::i][tool call: %s][-:-:-]\nargs: %s\nid: %s\n", icon, messages[i].GetText(), toolName, messages[i].ToolCall.Args, messages[i].ToolCall.ID), "\n\n", "\n")
}
continue continue
} }
resp[i] = msg.ToText(i) // Handle tool responses
if messages[i].Role == cfg.ToolRole || messages[i].Role == "tool" {
// Always show shell commands
if messages[i].IsShellCommand {
resp[i] = MsgToText(i, &messages[i])
continue
}
// Hide non-shell tool responses when collapsed
if toolCollapsed {
resp[i] = icon + "\n[yellow::i][tool resp (press Ctrl+T to expand)][-:-:-]\n"
continue
}
// When expanded, show tool responses
resp[i] = MsgToText(i, &messages[i])
continue
}
// INFO: skips system msg when showSys is false
if !showSys && messages[i].Role == "system" {
continue
}
resp[i] = MsgToText(i, &messages[i])
} }
return resp return resp
} }
@@ -1239,20 +1340,6 @@ func chatToText(messages []models.RoleMsg, showSys bool) string {
return text return text
} }
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 - use SetText to preserve ContentParts
msg.SetText(thinkRE.ReplaceAllString(msg.GetText(), ""))
msgs = append(msgs, msg)
}
chatBody.Messages = msgs
}
func addNewChat(chatName string) { func addNewChat(chatName string) {
id, err := store.ChatGetMaxID() id, err := store.ChatGetMaxID()
if err != nil { if err != nil {
@@ -1289,8 +1376,8 @@ func applyCharCard(cc *models.CharCard, loadHistory bool) {
} }
func charToStart(agentName string, keepSysP bool) bool { func charToStart(agentName string, keepSysP bool) bool {
cc, ok := sysMap[agentName] cc := GetCardByRole(agentName)
if !ok { if cc == nil {
return false return false
} }
applyCharCard(cc, keepSysP) applyCharCard(cc, keepSysP)
@@ -1307,11 +1394,28 @@ func updateModelLists() {
} }
// if llama.cpp started after gf-lt? // if llama.cpp started after gf-lt?
localModelsMu.Lock() localModelsMu.Lock()
LocalModels, err = fetchLCPModels() LocalModels, err = fetchLCPModelsWithLoadStatus()
localModelsMu.Unlock() localModelsMu.Unlock()
if err != nil { if err != nil {
logger.Warn("failed to fetch llama.cpp models", "error", err) logger.Warn("failed to fetch llama.cpp models", "error", err)
} }
// set already loaded model in llama.cpp
if strings.Contains(cfg.CurrentAPI, "localhost") || strings.Contains(cfg.CurrentAPI, "127.0.0.1") {
localModelsMu.Lock()
defer localModelsMu.Unlock()
for i := range LocalModels {
if strings.Contains(LocalModels[i], models.LoadedMark) {
m := strings.TrimPrefix(LocalModels[i], models.LoadedMark)
cfg.CurrentModel = m
chatBody.Model = m
cachedModelColor = "green"
updateStatusLine()
updateToolCapabilities()
app.Draw()
return
}
}
}
} }
func refreshLocalModelsIfEmpty() { func refreshLocalModelsIfEmpty() {
@@ -1334,15 +1438,15 @@ func refreshLocalModelsIfEmpty() {
func summarizeAndStartNewChat() { func summarizeAndStartNewChat() {
if len(chatBody.Messages) == 0 { if len(chatBody.Messages) == 0 {
_ = notifyUser("info", "No chat history to summarize") showToast("info", "No chat history to summarize")
return return
} }
_ = notifyUser("info", "Summarizing chat history...") showToast("info", "Summarizing chat history...")
// Call the summarize_chat tool via agent // Call the summarize_chat tool via agent
summaryBytes := callToolWithAgent("summarize_chat", map[string]string{}) summaryBytes := callToolWithAgent("summarize_chat", map[string]string{})
summary := string(summaryBytes) summary := string(summaryBytes)
if summary == "" { if summary == "" {
_ = notifyUser("error", "Failed to generate summary") showToast("error", "Failed to generate summary")
return return
} }
// Start a new chat // Start a new chat
@@ -1361,7 +1465,7 @@ func summarizeAndStartNewChat() {
if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil { if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil {
logger.Warn("failed to update storage after injecting summary", "error", err) logger.Warn("failed to update storage after injecting summary", "error", err)
} }
_ = notifyUser("info", "Chat summarized and new chat started with summary as tool response") showToast("info", "Chat summarized and new chat started with summary as tool response")
} }
func init() { func init() {
@@ -1374,15 +1478,6 @@ func init() {
os.Exit(1) os.Exit(1)
return return
} }
// Set image base directory for path display
baseDir := cfg.FilePickerDir
if baseDir == "" || baseDir == "." {
// Resolve "." to current working directory
if wd, err := os.Getwd(); err == nil {
baseDir = wd
}
}
models.SetImageBaseDir(baseDir)
defaultStarter = []models.RoleMsg{ defaultStarter = []models.RoleMsg{
{Role: "system", Content: basicSysMsg}, {Role: "system", Content: basicSysMsg},
{Role: cfg.AssistantRole, Content: defaultFirstMsg}, {Role: cfg.AssistantRole, Content: defaultFirstMsg},
@@ -1397,8 +1492,6 @@ func init() {
} }
// load cards // load cards
basicCard.Role = cfg.AssistantRole basicCard.Role = cfg.AssistantRole
// toolCard.Role = cfg.AssistantRole
//
logLevel.Set(slog.LevelInfo) logLevel.Set(slog.LevelInfo)
logger = slog.New(slog.NewTextHandler(logfile, &slog.HandlerOptions{Level: logLevel})) logger = slog.New(slog.NewTextHandler(logfile, &slog.HandlerOptions{Level: logLevel}))
store = storage.NewProviderSQL(cfg.DBPATH, logger) store = storage.NewProviderSQL(cfg.DBPATH, logger)
@@ -1431,6 +1524,23 @@ func init() {
if cfg.STT_ENABLED { if cfg.STT_ENABLED {
asr = NewSTT(logger, cfg) asr = NewSTT(logger, cfg)
} }
if cfg.PlaywrightEnabled {
if err := checkPlaywright(); err != nil {
// slow, need a faster check if playwright install
if err := installPW(); err != nil {
logger.Error("failed to install playwright", "error", err)
cancel()
os.Exit(1)
return
}
if err := checkPlaywright(); err != nil {
logger.Error("failed to run playwright", "error", err)
cancel()
os.Exit(1)
return
}
}
}
// Initialize scrollToEndEnabled based on config // Initialize scrollToEndEnabled based on config
scrollToEndEnabled = cfg.AutoScrollEnabled scrollToEndEnabled = cfg.AutoScrollEnabled
go updateModelLists() go updateModelLists()

View File

@@ -27,7 +27,6 @@ AutoCleanToolCallsFromCtx = false
RAGEnabled = false RAGEnabled = false
RAGBatchSize = 1 RAGBatchSize = 1
RAGWordLimit = 80 RAGWordLimit = 80
RAGWorkers = 2
RAGDir = "ragimport" RAGDir = "ragimport"
# extra tts # extra tts
TTS_ENABLED = false TTS_ENABLED = false
@@ -57,3 +56,6 @@ StripThinkingFromAPI = true # Strip <think> blocks from messages before sending
# Valid values: xhigh, high, medium, low, minimal, none (empty or none = disabled) # Valid values: xhigh, high, medium, low, minimal, none (empty or none = disabled)
# Models that support reasoning will include thinking content wrapped in <think> tags # Models that support reasoning will include thinking content wrapped in <think> tags
ReasoningEffort = "medium" ReasoningEffort = "medium"
# playwright tools
PlaywrightEnabled = false
PlaywrightDebug = false

View File

@@ -39,7 +39,6 @@ type Config struct {
// rag settings // rag settings
RAGEnabled bool `toml:"RAGEnabled"` RAGEnabled bool `toml:"RAGEnabled"`
RAGDir string `toml:"RAGDir"` RAGDir string `toml:"RAGDir"`
RAGWorkers uint32 `toml:"RAGWorkers"`
RAGBatchSize int `toml:"RAGBatchSize"` RAGBatchSize int `toml:"RAGBatchSize"`
RAGWordLimit uint32 `toml:"RAGWordLimit"` RAGWordLimit uint32 `toml:"RAGWordLimit"`
// deepseek // deepseek
@@ -71,6 +70,9 @@ type Config struct {
CharSpecificContextEnabled bool `toml:"CharSpecificContextEnabled"` CharSpecificContextEnabled bool `toml:"CharSpecificContextEnabled"`
CharSpecificContextTag string `toml:"CharSpecificContextTag"` CharSpecificContextTag string `toml:"CharSpecificContextTag"`
AutoTurn bool `toml:"AutoTurn"` AutoTurn bool `toml:"AutoTurn"`
// playwright browser
PlaywrightEnabled bool `toml:"PlaywrightEnabled"`
PlaywrightDebug bool `toml:"PlaywrightDebug"` // !headless
} }
func LoadConfig(fn string) (*Config, error) { func LoadConfig(fn string) (*Config, error) {

View File

@@ -80,9 +80,6 @@ This document explains how to set up and configure the application using the `co
#### RAGWordLimit (`80`) #### RAGWordLimit (`80`)
- Maximum number of words in a batch to tokenize and store. - Maximum number of words in a batch to tokenize and store.
#### RAGWorkers (`2`)
- Number of concurrent workers for RAG processing.
#### RAGDir (`"ragimport"`) #### RAGDir (`"ragimport"`)
- Directory containing documents for RAG processing. - Directory containing documents for RAG processing.
@@ -165,6 +162,15 @@ Those could be switched in program, but also bould be setup in config.
#### ToolUse #### ToolUse
- Enable or disable explanation of tools to llm, so it could use them. - Enable or disable explanation of tools to llm, so it could use them.
#### Playwright Browser Automation
These settings enable browser automation tools available to the LLM.
- **PlaywrightEnabled** (`false`)
- Enable or disable Playwright browser automation tools for the LLM. When enabled, the LLM can use tools like `pw_browser`, `pw_close`, and `pw_status` to automate browser interactions.
- **PlaywrightDebug** (`false`)
- Enable debug mode for Playwright browser. When set to `true`, the browser runs in visible (non-headless) mode, displaying the GUI for debugging purposes. When `false`, the browser runs in headless mode by default.
### StripThinkingFromAPI (`true`) ### StripThinkingFromAPI (`true`)
- Strip thinking blocks from messages before sending to LLM. Keeps them in chat history for local viewing but reduces token usage in API calls. - Strip thinking blocks from messages before sending to LLM. Keeps them in chat history for local viewing but reduces token usage in API calls.

4
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/GrailFinder/google-translate-tts v0.1.3 github.com/GrailFinder/google-translate-tts v0.1.3
github.com/GrailFinder/searchagent v0.2.0 github.com/GrailFinder/searchagent v0.2.0
github.com/PuerkitoBio/goquery v1.11.0 github.com/PuerkitoBio/goquery v1.11.0
github.com/deckarep/golang-set/v2 v2.8.0
github.com/gdamore/tcell/v2 v2.13.2 github.com/gdamore/tcell/v2 v2.13.2
github.com/glebarez/go-sqlite v1.22.0 github.com/glebarez/go-sqlite v1.22.0
github.com/gopxl/beep/v2 v2.1.1 github.com/gopxl/beep/v2 v2.1.1
@@ -14,6 +15,7 @@ require (
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
github.com/neurosnap/sentences v1.1.2 github.com/neurosnap/sentences v1.1.2
github.com/playwright-community/playwright-go v0.5700.1
github.com/rivo/tview v0.42.0 github.com/rivo/tview v0.42.0
github.com/yuin/goldmark v1.4.13 github.com/yuin/goldmark v1.4.13
) )
@@ -24,6 +26,8 @@ require (
github.com/ebitengine/oto/v3 v3.4.0 // indirect github.com/ebitengine/oto/v3 v3.4.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/hajimehoshi/oto/v2 v2.3.1 // indirect github.com/hajimehoshi/oto/v2 v2.3.1 // indirect

14
go.sum
View File

@@ -10,8 +10,11 @@ github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ= github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ=
@@ -24,8 +27,13 @@ github.com/gdamore/tcell/v2 v2.13.2 h1:5j4srfF8ow3HICOv/61/sOhQtA25qxEB2XR3Q/Bhx
github.com/gdamore/tcell/v2 v2.13.2/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/gdamore/tcell/v2 v2.13.2/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
@@ -59,6 +67,8 @@ github.com/neurosnap/sentences v1.1.2 h1:iphYOzx/XckXeBiLIUBkPu2EKMJ+6jDbz/sLJZ7
github.com/neurosnap/sentences v1.1.2/go.mod h1:/pwU4E9XNL21ygMIkOIllv/SMy2ujHwpf8GQPu1YPbQ= github.com/neurosnap/sentences v1.1.2/go.mod h1:/pwU4E9XNL21ygMIkOIllv/SMy2ujHwpf8GQPu1YPbQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/playwright-community/playwright-go v0.5700.1 h1:PNFb1byWqrTT720rEO0JL88C6Ju0EmUnR5deFLvtP/U=
github.com/playwright-community/playwright-go v0.5700.1/go.mod h1:MlSn1dZrx8rszbCxY6x3qK89ZesJUYVx21B2JnkoNF0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -67,6 +77,8 @@ github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
@@ -152,6 +164,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=

View File

@@ -11,12 +11,11 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv"
"strings" "strings"
"time" "time"
"unicode" "unicode"
"math/rand/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
) )
@@ -29,7 +28,6 @@ func startModelColorUpdater() {
go func() { go func() {
ticker := time.NewTicker(5 * time.Second) ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop() defer ticker.Stop()
// Initial check // Initial check
updateCachedModelColor() updateCachedModelColor()
for range ticker.C { for range ticker.C {
@@ -44,7 +42,6 @@ func updateCachedModelColor() {
cachedModelColor = "orange" cachedModelColor = "orange"
return return
} }
// Check if model is loaded // Check if model is loaded
loaded, err := isModelLoaded(chatBody.Model) loaded, err := isModelLoaded(chatBody.Model)
if err != nil { if err != nil {
@@ -68,6 +65,14 @@ func isASCII(s string) bool {
return true return true
} }
func mapToString[V any](m map[string]V) string {
rs := strings.Builder{}
for k, v := range m {
fmt.Fprintf(&rs, "%v: %v\n", k, v)
}
return rs.String()
}
// stripThinkingFromMsg removes thinking blocks from assistant messages. // stripThinkingFromMsg removes thinking blocks from assistant messages.
// Skips user, tool, and system messages as they may contain thinking examples. // Skips user, tool, and system messages as they may contain thinking examples.
func stripThinkingFromMsg(msg *models.RoleMsg) *models.RoleMsg { func stripThinkingFromMsg(msg *models.RoleMsg) *models.RoleMsg {
@@ -193,7 +198,11 @@ func initSysCards() ([]string, error) {
logger.Warn("empty role", "file", cc.FilePath) logger.Warn("empty role", "file", cc.FilePath)
continue continue
} }
sysMap[cc.Role] = cc if cc.ID == "" {
cc.ID = models.ComputeCardID(cc.Role, cc.FilePath)
}
sysMap[cc.ID] = cc
roleToID[cc.Role] = cc.ID
labels = append(labels, cc.Role) labels = append(labels, cc.Role)
} }
return labels, nil return labels, nil
@@ -282,24 +291,25 @@ func listRolesWithUser() []string {
return result return result
} }
func loadImage() { func loadImage() error {
filepath := defaultImage filepath := defaultImage
cc, ok := sysMap[cfg.AssistantRole] cc := GetCardByRole(cfg.AssistantRole)
if ok { if cc != nil {
if strings.HasSuffix(cc.FilePath, ".png") { if strings.HasSuffix(cc.FilePath, ".png") {
filepath = cc.FilePath filepath = cc.FilePath
} }
} }
file, err := os.Open(filepath) file, err := os.Open(filepath)
if err != nil { if err != nil {
panic(err) return fmt.Errorf("failed to open image: %w", err)
} }
defer file.Close() defer file.Close()
img, _, err := image.Decode(file) img, _, err := image.Decode(file)
if err != nil { if err != nil {
panic(err) return fmt.Errorf("failed to decode image: %w", err)
} }
imgView.SetImage(img) imgView.SetImage(img)
return nil
} }
func strInSlice(s string, sl []string) bool { func strInSlice(s string, sl []string) bool {
@@ -372,17 +382,88 @@ func makeStatusLine() string {
roleInject := fmt.Sprintf(" | [%s:-:b]role injection[-:-:-] (alt+7)", boolColors[injectRole]) roleInject := fmt.Sprintf(" | [%s:-:b]role injection[-:-:-] (alt+7)", boolColors[injectRole])
statusLine += roleInject statusLine += roleInject
} }
// context tokens
contextTokens := getContextTokens()
maxCtx := getMaxContextTokens()
if maxCtx == 0 {
maxCtx = 16384
}
if contextTokens > 0 {
contextInfo := fmt.Sprintf(" | context-estim: [orange:-:b]%d/%d[-:-:-]", contextTokens, maxCtx)
statusLine += contextInfo
}
return statusLine + imageInfo + shellModeInfo return statusLine + imageInfo + shellModeInfo
} }
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") func getContextTokens() int {
if chatBody == nil || chatBody.Messages == nil {
func randString(n int) string { return 0
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.IntN(len(letters))]
} }
return string(b) total := 0
messages := chatBody.Messages
for i := range messages {
msg := &messages[i]
if msg.Stats != nil && msg.Stats.Tokens > 0 {
total += msg.Stats.Tokens
} else if msg.GetText() != "" {
total += len(msg.GetText()) / 4
}
}
return total
}
const deepseekContext = 128000
func getMaxContextTokens() int {
if chatBody == nil || chatBody.Model == "" {
return 0
}
modelName := chatBody.Model
switch {
case strings.Contains(cfg.CurrentAPI, "openrouter"):
if orModelsData != nil {
for i := range orModelsData.Data {
m := &orModelsData.Data[i]
if m.ID == modelName {
return m.ContextLength
}
}
}
case strings.Contains(cfg.CurrentAPI, "deepseek"):
return deepseekContext
default:
if localModelsData != nil {
for i := range localModelsData.Data {
m := &localModelsData.Data[i]
if m.ID == modelName {
for _, arg := range m.Status.Args {
if strings.HasPrefix(arg, "--ctx-size") {
if strings.Contains(arg, "=") {
val := strings.Split(arg, "=")[1]
if n, err := strconv.Atoi(val); err == nil {
return n
}
} else {
idx := -1
for j, a := range m.Status.Args {
if a == "--ctx-size" && j+1 < len(m.Status.Args) {
idx = j + 1
break
}
}
if idx != -1 {
if n, err := strconv.Atoi(m.Status.Args[idx]); err == nil {
return n
}
}
}
}
}
}
}
}
}
return 0
} }
// set of roles within card definition and mention in chat history // set of roles within card definition and mention in chat history
@@ -392,13 +473,9 @@ func listChatRoles() []string {
if !ok { if !ok {
return cbc return cbc
} }
currentCard, ok := sysMap[currentChat.Agent] currentCard := GetCardByRole(currentChat.Agent)
if !ok { if currentCard == nil {
// case which won't let to switch roles: logger.Warn("failed to find current card", "agent", currentChat.Agent)
// started new chat (basic_sys or any other), at the start it yet be saved or have chatbody
// if it does not have a card or chars, it'll return an empty slice
// log error
logger.Warn("failed to find current card in sysMap", "agent", currentChat.Agent, "sysMap", sysMap)
return cbc return cbc
} }
charset := []string{} charset := []string{}
@@ -414,10 +491,7 @@ func listChatRoles() []string {
func deepseekModelValidator() error { func deepseekModelValidator() error {
if cfg.CurrentAPI == cfg.DeepSeekChatAPI || cfg.CurrentAPI == cfg.DeepSeekCompletionAPI { if cfg.CurrentAPI == cfg.DeepSeekChatAPI || cfg.CurrentAPI == cfg.DeepSeekCompletionAPI {
if chatBody.Model != "deepseek-chat" && chatBody.Model != "deepseek-reasoner" { if chatBody.Model != "deepseek-chat" && chatBody.Model != "deepseek-reasoner" {
if err := notifyUser("bad request", "wrong deepseek model name"); err != nil { showToast("bad request", "wrong deepseek model name")
logger.Warn("failed ot notify user", "error", err)
return err
}
return nil return nil
} }
} }
@@ -465,9 +539,8 @@ func updateFlexLayout() {
} }
func executeCommandAndDisplay(cmdText string) { func executeCommandAndDisplay(cmdText string) {
// Parse the command (split by spaces, but handle quoted arguments) cmdText = strings.TrimSpace(cmdText)
cmdParts := parseCommand(cmdText) if cmdText == "" {
if len(cmdParts) == 0 {
fmt.Fprintf(textView, "\n[red]Error: No command provided[-:-:-]\n") fmt.Fprintf(textView, "\n[red]Error: No command provided[-:-:-]\n")
if scrollToEndEnabled { if scrollToEndEnabled {
textView.ScrollToEnd() textView.ScrollToEnd()
@@ -475,14 +548,58 @@ func executeCommandAndDisplay(cmdText string) {
colorText() colorText()
return return
} }
command := cmdParts[0] workingDir := cfg.FilePickerDir
args := []string{} // Handle cd command specially to update working directory
if len(cmdParts) > 1 { if strings.HasPrefix(cmdText, "cd ") {
args = cmdParts[1:] newDir := strings.TrimPrefix(cmdText, "cd ")
newDir = strings.TrimSpace(newDir)
// Handle cd ~ or cdHOME
if strings.HasPrefix(newDir, "~") {
home := os.Getenv("HOME")
newDir = strings.Replace(newDir, "~", home, 1)
}
// Check if directory exists
if _, err := os.Stat(newDir); err == nil {
workingDir = newDir
cfg.FilePickerDir = workingDir
// Update shell input label with new directory
shellInput.SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir))
outputContent := workingDir
// Add the command being executed to the chat
fmt.Fprintf(textView, "\n[-:-:b](%d) <%s>: [-:-:-]\n$ %s\n",
len(chatBody.Messages), cfg.ToolRole, cmdText)
fmt.Fprintf(textView, "%s\n", outputContent)
combinedMsg := models.RoleMsg{
Role: cfg.ToolRole,
Content: "$ " + cmdText + "\n\n" + outputContent,
}
chatBody.Messages = append(chatBody.Messages, combinedMsg)
if scrollToEndEnabled {
textView.ScrollToEnd()
}
colorText()
return
} else {
outputContent := "cd: " + newDir + ": No such file or directory"
fmt.Fprintf(textView, "\n[-:-:b](%d) <%s>: [-:-:-]\n$ %s\n",
len(chatBody.Messages), cfg.ToolRole, cmdText)
fmt.Fprintf(textView, "[red]%s[-:-:-]\n", outputContent)
combinedMsg := models.RoleMsg{
Role: cfg.ToolRole,
Content: "$ " + cmdText + "\n\n" + outputContent,
}
chatBody.Messages = append(chatBody.Messages, combinedMsg)
if scrollToEndEnabled {
textView.ScrollToEnd()
}
colorText()
return
}
} }
// Create the command execution
cmd := exec.Command(command, args...) // Use /bin/sh to support pipes, redirects, etc.
cmd.Dir = cfg.FilePickerDir cmd := exec.Command("/bin/sh", "-c", cmdText)
cmd.Dir = workingDir
// Execute the command and get output // Execute the command and get output
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
// Add the command being executed to the chat // Add the command being executed to the chat
@@ -531,42 +648,6 @@ func executeCommandAndDisplay(cmdText string) {
shellHistoryPos = -1 shellHistoryPos = -1
} }
// parseCommand splits command string handling quotes properly
func parseCommand(cmd string) []string {
var args []string
var current string
var inQuotes bool
var quoteChar rune
for _, r := range cmd {
switch r {
case '"', '\'':
if inQuotes {
if r == quoteChar {
inQuotes = false
} else {
current += string(r)
}
} else {
inQuotes = true
quoteChar = r
}
case ' ', '\t':
if inQuotes {
current += string(r)
} else if current != "" {
args = append(args, current)
current = ""
}
default:
current += string(r)
}
}
if current != "" {
args = append(args, current)
}
return args
}
// == search == // == search ==
// Global variables for search state // Global variables for search state
@@ -610,9 +691,7 @@ func performSearch(term string) {
searchResults = nil searchResults = nil
searchResultLengths = nil searchResultLengths = nil
notification := "Pattern not found: " + term notification := "Pattern not found: " + term
if err := notifyUser("search", notification); err != nil { showToast("search", notification)
logger.Error("failed to send notification", "error", err)
}
return return
} }
// Store the formatted text positions and lengths for accurate highlighting // Store the formatted text positions and lengths for accurate highlighting
@@ -645,9 +724,7 @@ func highlightCurrentMatch() {
textView.Highlight(currentRegion).ScrollToHighlight() textView.Highlight(currentRegion).ScrollToHighlight()
// Send notification about which match we're at // Send notification about which match we're at
notification := fmt.Sprintf("Match %d of %d", searchIndex+1, len(searchResults)) notification := fmt.Sprintf("Match %d of %d", searchIndex+1, len(searchResults))
if err := notifyUser("search", notification); err != nil { showToast("search", notification)
logger.Error("failed to send notification", "error", err)
}
} }
// showSearchBar shows the search input field as an overlay // showSearchBar shows the search input field as an overlay
@@ -737,9 +814,7 @@ func addRegionTags(text string, positions []int, lengths []int, currentIdx int,
// searchNext finds the next occurrence of the search term // searchNext finds the next occurrence of the search term
func searchNext() { func searchNext() {
if len(searchResults) == 0 { if len(searchResults) == 0 {
if err := notifyUser("search", "No search results to navigate"); err != nil { showToast("search", "No search results to navigate")
logger.Error("failed to send notification", "error", err)
}
return return
} }
searchIndex = (searchIndex + 1) % len(searchResults) searchIndex = (searchIndex + 1) % len(searchResults)
@@ -749,9 +824,7 @@ func searchNext() {
// searchPrev finds the previous occurrence of the search term // searchPrev finds the previous occurrence of the search term
func searchPrev() { func searchPrev() {
if len(searchResults) == 0 { if len(searchResults) == 0 {
if err := notifyUser("search", "No search results to navigate"); err != nil { showToast("search", "No search results to navigate")
logger.Error("failed to send notification", "error", err)
}
return return
} }
if searchIndex == 0 { if searchIndex == 0 {
@@ -806,3 +879,91 @@ func scanFiles(dir, filter string) []string {
scanRecursive(dir, 0, "") scanRecursive(dir, 0, "")
return files return files
} }
// models logic that is too complex for models package
func MsgToText(i int, m *models.RoleMsg) string {
var contentStr string
var imageIndicators []string
if !m.HasContentParts {
contentStr = m.Content
} else {
var textParts []string
for _, part := range m.ContentParts {
switch p := part.(type) {
case models.TextContentPart:
if p.Type == "text" {
textParts = append(textParts, p.Text)
}
case models.ImageContentPart:
displayPath := p.Path
if displayPath == "" {
displayPath = "image"
} else {
displayPath = extractDisplayPath(displayPath, cfg.FilePickerDir)
}
imageIndicators = append(imageIndicators, fmt.Sprintf("[orange::i][image: %s][-:-:-]", displayPath))
case map[string]any:
if partType, exists := p["type"]; exists {
switch partType {
case "text":
if textVal, textExists := p["text"]; textExists {
if textStr, isStr := textVal.(string); isStr {
textParts = append(textParts, textStr)
}
}
case "image_url":
var displayPath string
if pathVal, pathExists := p["path"]; pathExists {
if pathStr, isStr := pathVal.(string); isStr && pathStr != "" {
displayPath = extractDisplayPath(pathStr, cfg.FilePickerDir)
}
}
if displayPath == "" {
displayPath = "image"
}
imageIndicators = append(imageIndicators, fmt.Sprintf("[orange::i][image: %s][-:-:-]", displayPath))
}
}
}
}
contentStr = strings.Join(textParts, " ") + " "
}
contentStr, _ = strings.CutPrefix(contentStr, m.Role+":")
icon := fmt.Sprintf("(%d) <%s>: ", i, m.Role)
var finalContent strings.Builder
if len(imageIndicators) > 0 {
for _, indicator := range imageIndicators {
finalContent.WriteString(indicator)
finalContent.WriteString("\n")
}
}
finalContent.WriteString(contentStr)
if m.Stats != nil {
fmt.Fprintf(&finalContent, "\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())
return strings.ReplaceAll(textMsg, "\n\n", "\n")
}
// extractDisplayPath returns a path suitable for display, potentially relative to imageBaseDir
func extractDisplayPath(p, bp string) string {
if p == "" {
return ""
}
// If base directory is set, try to make path relative to it
if bp != "" {
if rel, err := filepath.Rel(bp, p); err == nil {
// Check if relative path doesn't start with ".." (meaning it's within base dir)
// If it starts with "..", we might still want to show it as relative
// but for now we show full path if it goes outside base dir
if !strings.HasPrefix(rel, "..") {
p = rel
}
}
}
// Truncate long paths to last 60 characters if needed
if len(p) > 60 {
return "..." + p[len(p)-60:]
}
return p
}

172
llm.go
View File

@@ -3,7 +3,6 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"gf-lt/models" "gf-lt/models"
"io" "io"
"strings" "strings"
@@ -14,8 +13,8 @@ var lastImg string // for ctrl+j
// containsToolSysMsg checks if the toolSysMsg already exists in the chat body // containsToolSysMsg checks if the toolSysMsg already exists in the chat body
func containsToolSysMsg() bool { func containsToolSysMsg() bool {
for _, msg := range chatBody.Messages { for i := range chatBody.Messages {
if msg.Role == cfg.ToolRole && msg.Content == toolSysMsg { if chatBody.Messages[i].Role == cfg.ToolRole && chatBody.Messages[i].Content == toolSysMsg {
return true return true
} }
} }
@@ -119,25 +118,22 @@ func (lcp LCPCompletion) FormMsg(msg, role string, resume bool) (io.Reader, erro
logger.Debug("formmsg lcpcompletion", "link", cfg.CurrentAPI) logger.Debug("formmsg lcpcompletion", "link", cfg.CurrentAPI)
localImageAttachmentPath := imageAttachmentPath localImageAttachmentPath := imageAttachmentPath
var multimodalData []string var multimodalData []string
if localImageAttachmentPath != "" {
imageURL, err := models.CreateImageURLFromPath(localImageAttachmentPath)
if err != nil {
logger.Error("failed to create image URL from path for completion",
"error", err, "path", localImageAttachmentPath)
return nil, err
}
// Extract base64 part from data URL (e.g., "data:image/jpeg;base64,...")
parts := strings.SplitN(imageURL, ",", 2)
if len(parts) == 2 {
multimodalData = append(multimodalData, parts[1])
} else {
logger.Error("invalid image data URL format", "url", imageURL)
return nil, errors.New("invalid image data URL format")
}
imageAttachmentPath = "" // Clear the attachment after use
}
if msg != "" { // otherwise let the bot to continue if msg != "" { // otherwise let the bot to continue
newMsg := models.RoleMsg{Role: role, Content: msg} var newMsg models.RoleMsg
if localImageAttachmentPath != "" {
newMsg = models.NewMultimodalMsg(role, []any{})
newMsg.AddTextPart(msg)
imageURL, err := models.CreateImageURLFromPath(localImageAttachmentPath)
if err != nil {
logger.Error("failed to create image URL from path for completion",
"error", err, "path", localImageAttachmentPath)
return nil, err
}
newMsg.AddImagePart(imageURL, localImageAttachmentPath)
imageAttachmentPath = "" // Clear the attachment after use
} else { // not a multimodal msg or image passed in tool call
newMsg = models.RoleMsg{Role: role, Content: msg}
}
newMsg = *processMessageTag(&newMsg) newMsg = *processMessageTag(&newMsg)
chatBody.Messages = append(chatBody.Messages, newMsg) chatBody.Messages = append(chatBody.Messages, newMsg)
} }
@@ -146,22 +142,40 @@ func (lcp LCPCompletion) FormMsg(msg, role string, resume bool) (io.Reader, erro
chatBody.Messages = append(chatBody.Messages, models.RoleMsg{Role: cfg.ToolRole, Content: toolSysMsg}) chatBody.Messages = append(chatBody.Messages, models.RoleMsg{Role: cfg.ToolRole, Content: toolSysMsg})
} }
filteredMessages, botPersona := filterMessagesForCurrentCharacter(chatBody.Messages) filteredMessages, botPersona := filterMessagesForCurrentCharacter(chatBody.Messages)
// Build prompt and extract images inline as we process each message
messages := make([]string, len(filteredMessages)) messages := make([]string, len(filteredMessages))
for i, m := range filteredMessages { for i := range filteredMessages {
messages[i] = stripThinkingFromMsg(&m).ToPrompt() m := stripThinkingFromMsg(&filteredMessages[i])
messages[i] = m.ToPrompt()
// Extract images from this message and add marker inline
if len(m.ContentParts) > 0 {
for _, part := range m.ContentParts {
var imgURL string
// Check for struct type
if imgPart, ok := part.(models.ImageContentPart); ok {
imgURL = imgPart.ImageURL.URL
} else if partMap, ok := part.(map[string]any); ok {
// Check for map type (from JSON unmarshaling)
if partType, exists := partMap["type"]; exists && partType == "image_url" {
if imgURLMap, ok := partMap["image_url"].(map[string]any); ok {
if url, ok := imgURLMap["url"].(string); ok {
imgURL = url
}
}
}
}
if imgURL != "" {
// Extract base64 part from data URL (e.g., "data:image/jpeg;base64,...")
parts := strings.SplitN(imgURL, ",", 2)
if len(parts) == 2 {
multimodalData = append(multimodalData, parts[1])
messages[i] += " <__media__>"
}
}
}
}
} }
prompt := strings.Join(messages, "\n") prompt := strings.Join(messages, "\n")
// Add multimodal media markers to the prompt text when multimodal data is present
// This is required by llama.cpp multimodal models so they know where to insert media
if len(multimodalData) > 0 {
// Add a media marker for each item in the multimodal data
var sb strings.Builder
sb.WriteString(prompt)
for range multimodalData {
sb.WriteString(" <__media__>") // llama.cpp default multimodal marker
}
prompt = sb.String()
}
// needs to be after <__media__> if there are images // needs to be after <__media__> if there are images
if !resume { if !resume {
botMsgStart := "\n" + botPersona + ":\n" botMsgStart := "\n" + botPersona + ":\n"
@@ -210,11 +224,9 @@ func (op LCPChat) ParseChunk(data []byte) (*models.TextChunk, error) {
logger.Error("failed to decode", "error", err, "line", string(data)) logger.Error("failed to decode", "error", err, "line", string(data))
return nil, err return nil, err
} }
// Handle multiple choices safely
if len(llmchunk.Choices) == 0 { if len(llmchunk.Choices) == 0 {
logger.Warn("LCPChat ParseChunk: no choices in response", "data", string(data)) logger.Warn("LCPChat empty chunk choices", "raw_data", string(data), "chunk", llmchunk)
return &models.TextChunk{Finished: true}, nil return &models.TextChunk{}, nil
} }
lastChoice := llmchunk.Choices[len(llmchunk.Choices)-1] lastChoice := llmchunk.Choices[len(llmchunk.Choices)-1]
resp := &models.TextChunk{ resp := &models.TextChunk{
@@ -289,14 +301,23 @@ func (op LCPChat) FormMsg(msg, role string, resume bool) (io.Reader, error) {
Model: chatBody.Model, Model: chatBody.Model,
Stream: chatBody.Stream, Stream: chatBody.Stream,
} }
for i, msg := range filteredMessages { for i := range filteredMessages {
strippedMsg := *stripThinkingFromMsg(&msg) strippedMsg := *stripThinkingFromMsg(&filteredMessages[i])
if strippedMsg.Role == cfg.UserRole { switch strippedMsg.Role {
case cfg.UserRole:
bodyCopy.Messages[i] = strippedMsg bodyCopy.Messages[i] = strippedMsg
bodyCopy.Messages[i].Role = "user" bodyCopy.Messages[i].Role = "user"
} else { case cfg.AssistantRole:
bodyCopy.Messages[i] = strippedMsg
bodyCopy.Messages[i].Role = "assistant"
case cfg.ToolRole:
bodyCopy.Messages[i] = strippedMsg
bodyCopy.Messages[i].Role = "tool"
default:
bodyCopy.Messages[i] = strippedMsg bodyCopy.Messages[i] = strippedMsg
} }
// Clear ToolCalls - they're stored in chat history for display but not sent to LLM
// bodyCopy.Messages[i].ToolCall = nil
} }
// Clean null/empty messages to prevent API issues // Clean null/empty messages to prevent API issues
bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages) bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages)
@@ -326,6 +347,10 @@ func (ds DeepSeekerCompletion) ParseChunk(data []byte) (*models.TextChunk, error
logger.Error("failed to decode", "error", err, "line", string(data)) logger.Error("failed to decode", "error", err, "line", string(data))
return nil, err return nil, err
} }
if len(llmchunk.Choices) == 0 {
logger.Warn("empty chunk choices", "raw_data", string(data), "chunk", llmchunk)
return &models.TextChunk{}, nil
}
resp := &models.TextChunk{ resp := &models.TextChunk{
Chunk: llmchunk.Choices[0].Text, Chunk: llmchunk.Choices[0].Text,
} }
@@ -358,8 +383,8 @@ func (ds DeepSeekerCompletion) FormMsg(msg, role string, resume bool) (io.Reader
} }
filteredMessages, botPersona := filterMessagesForCurrentCharacter(chatBody.Messages) filteredMessages, botPersona := filterMessagesForCurrentCharacter(chatBody.Messages)
messages := make([]string, len(filteredMessages)) messages := make([]string, len(filteredMessages))
for i, m := range filteredMessages { for i := range filteredMessages {
messages[i] = stripThinkingFromMsg(&m).ToPrompt() messages[i] = stripThinkingFromMsg(&filteredMessages[i]).ToPrompt()
} }
prompt := strings.Join(messages, "\n") prompt := strings.Join(messages, "\n")
// strings builder? // strings builder?
@@ -391,6 +416,10 @@ func (ds DeepSeekerChat) ParseChunk(data []byte) (*models.TextChunk, error) {
return nil, err return nil, err
} }
resp := &models.TextChunk{} resp := &models.TextChunk{}
if len(llmchunk.Choices) == 0 {
logger.Warn("empty chunk choices", "raw_data", string(data), "chunk", llmchunk)
return resp, nil
}
if llmchunk.Choices[0].FinishReason != "" { if llmchunk.Choices[0].FinishReason != "" {
if llmchunk.Choices[0].Delta.Content != "" { if llmchunk.Choices[0].Delta.Content != "" {
logger.Error("text inside of finish llmchunk", "chunk", llmchunk) logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
@@ -429,14 +458,27 @@ func (ds DeepSeekerChat) FormMsg(msg, role string, resume bool) (io.Reader, erro
Model: chatBody.Model, Model: chatBody.Model,
Stream: chatBody.Stream, Stream: chatBody.Stream,
} }
for i, msg := range filteredMessages { for i := range filteredMessages {
strippedMsg := *stripThinkingFromMsg(&msg) strippedMsg := *stripThinkingFromMsg(&filteredMessages[i])
if strippedMsg.Role == cfg.UserRole || i == 1 { switch strippedMsg.Role {
case cfg.UserRole:
if i == 1 {
bodyCopy.Messages[i] = strippedMsg
bodyCopy.Messages[i].Role = "user"
} else {
bodyCopy.Messages[i] = strippedMsg
}
case cfg.AssistantRole:
bodyCopy.Messages[i] = strippedMsg bodyCopy.Messages[i] = strippedMsg
bodyCopy.Messages[i].Role = "user" bodyCopy.Messages[i].Role = "assistant"
} else { case cfg.ToolRole:
bodyCopy.Messages[i] = strippedMsg
bodyCopy.Messages[i].Role = "tool"
default:
bodyCopy.Messages[i] = strippedMsg bodyCopy.Messages[i] = strippedMsg
} }
// Clear ToolCalls - they're stored in chat history for display but not sent to LLM
// bodyCopy.Messages[i].ToolCall = nil
} }
// Clean null/empty messages to prevent API issues // Clean null/empty messages to prevent API issues
bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages) bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages)
@@ -460,6 +502,10 @@ func (or OpenRouterCompletion) ParseChunk(data []byte) (*models.TextChunk, error
logger.Error("failed to decode", "error", err, "line", string(data)) logger.Error("failed to decode", "error", err, "line", string(data))
return nil, err return nil, err
} }
if len(llmchunk.Choices) == 0 {
logger.Warn("empty chunk choices", "raw_data", string(data), "chunk", llmchunk)
return &models.TextChunk{}, nil
}
resp := &models.TextChunk{ resp := &models.TextChunk{
Chunk: llmchunk.Choices[len(llmchunk.Choices)-1].Text, Chunk: llmchunk.Choices[len(llmchunk.Choices)-1].Text,
} }
@@ -489,8 +535,8 @@ func (or OpenRouterCompletion) FormMsg(msg, role string, resume bool) (io.Reader
} }
filteredMessages, botPersona := filterMessagesForCurrentCharacter(chatBody.Messages) filteredMessages, botPersona := filterMessagesForCurrentCharacter(chatBody.Messages)
messages := make([]string, len(filteredMessages)) messages := make([]string, len(filteredMessages))
for i, m := range filteredMessages { for i := range filteredMessages {
messages[i] = stripThinkingFromMsg(&m).ToPrompt() messages[i] = stripThinkingFromMsg(&filteredMessages[i]).ToPrompt()
} }
prompt := strings.Join(messages, "\n") prompt := strings.Join(messages, "\n")
// strings builder? // strings builder?
@@ -522,6 +568,10 @@ func (or OpenRouterChat) ParseChunk(data []byte) (*models.TextChunk, error) {
logger.Error("failed to decode", "error", err, "line", string(data)) logger.Error("failed to decode", "error", err, "line", string(data))
return nil, err return nil, err
} }
if len(llmchunk.Choices) == 0 {
logger.Warn("empty chunk choices", "raw_data", string(data), "chunk", llmchunk)
return &models.TextChunk{}, nil
}
lastChoice := llmchunk.Choices[len(llmchunk.Choices)-1] lastChoice := llmchunk.Choices[len(llmchunk.Choices)-1]
resp := &models.TextChunk{ resp := &models.TextChunk{
Chunk: lastChoice.Delta.Content, Chunk: lastChoice.Delta.Content,
@@ -593,14 +643,24 @@ func (or OpenRouterChat) FormMsg(msg, role string, resume bool) (io.Reader, erro
Model: chatBody.Model, Model: chatBody.Model,
Stream: chatBody.Stream, Stream: chatBody.Stream,
} }
for i, msg := range filteredMessages { for i := range filteredMessages {
strippedMsg := *stripThinkingFromMsg(&msg) strippedMsg := *stripThinkingFromMsg(&filteredMessages[i])
bodyCopy.Messages[i] = strippedMsg switch strippedMsg.Role {
// Standardize role if it's a user role case cfg.UserRole:
if bodyCopy.Messages[i].Role == cfg.UserRole {
bodyCopy.Messages[i] = strippedMsg bodyCopy.Messages[i] = strippedMsg
bodyCopy.Messages[i].Role = "user" bodyCopy.Messages[i].Role = "user"
case cfg.AssistantRole:
bodyCopy.Messages[i] = strippedMsg
bodyCopy.Messages[i].Role = "assistant"
case cfg.ToolRole:
bodyCopy.Messages[i] = strippedMsg
bodyCopy.Messages[i].Role = "tool"
default:
bodyCopy.Messages[i] = strippedMsg
} }
// Clear ToolCalls - they're stored in chat history for display but not sent to LLM
// literally deletes data that we need
// bodyCopy.Messages[i].ToolCall = nil
} }
// Clean null/empty messages to prevent API issues // Clean null/empty messages to prevent API issues
bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages) bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages)

View File

@@ -16,8 +16,10 @@ var (
shellHistory []string shellHistory []string
shellHistoryPos int = -1 shellHistoryPos int = -1
thinkingCollapsed = false thinkingCollapsed = false
statusLineTempl = "help (F12) | 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)" toolCollapsed = true
statusLineTempl = "help (F12) | chat: [orange:-:b]%s[-:-:-] (F1) | [%s:-:b]tool use[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | [%s:-:b]skip LLM resp[-:-:-] (F10) | API: [orange:-:b]%s[-:-:-] (ctrl+v)\nwriting 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{}
app *tview.Application
) )
func main() { func main() {

View File

@@ -1,42 +0,0 @@
package main
import (
"fmt"
"gf-lt/config"
"gf-lt/models"
"strings"
"testing"
)
func TestRemoveThinking(t *testing.T) {
cases := []struct {
cb *models.ChatBody
toolMsgs uint8
}{
{cb: &models.ChatBody{
Stream: true,
Messages: []models.RoleMsg{
{Role: "tool", Content: "should be ommited"},
{Role: "system", Content: "should stay"},
{Role: "user", Content: "hello, how are you?"},
{Role: "assistant", Content: "Oh, hi. <think>I should thank user and continue the conversation</think> I am geat, thank you! How are you?"},
},
},
toolMsgs: uint8(1),
},
}
for i, tc := range cases {
t.Run(fmt.Sprintf("run_%d", i), func(t *testing.T) {
cfg = &config.Config{ToolRole: "tool"} // Initialize cfg.ToolRole for test
mNum := len(tc.cb.Messages)
removeThinking(tc.cb)
if len(tc.cb.Messages) != mNum-int(tc.toolMsgs) {
t.Errorf("failed to delete tools msg %v; expected %d, got %d", tc.cb.Messages, mNum-int(tc.toolMsgs), len(tc.cb.Messages))
}
for _, msg := range tc.cb.Messages {
if strings.Contains(msg.Content, "<think>") {
t.Errorf("msg contains think tag; msg: %s\n", msg.Content)
}
}
}) }
}

View File

@@ -1,6 +1,10 @@
package models package models
import "strings" import (
"crypto/md5"
"fmt"
"strings"
)
// https://github.com/malfoyslastname/character-card-spec-v2/blob/main/spec_v2.md // https://github.com/malfoyslastname/character-card-spec-v2/blob/main/spec_v2.md
// what a bloat; trim to Role->Msg pair and first msg // what a bloat; trim to Role->Msg pair and first msg
@@ -31,6 +35,7 @@ func (c *CharCardSpec) Simplify(userName, fpath string) *CharCard {
fm := strings.ReplaceAll(strings.ReplaceAll(c.FirstMes, "{{char}}", c.Name), "{{user}}", userName) fm := strings.ReplaceAll(strings.ReplaceAll(c.FirstMes, "{{char}}", c.Name), "{{user}}", userName)
sysPr := strings.ReplaceAll(strings.ReplaceAll(c.Description, "{{char}}", c.Name), "{{user}}", userName) sysPr := strings.ReplaceAll(strings.ReplaceAll(c.Description, "{{char}}", c.Name), "{{user}}", userName)
return &CharCard{ return &CharCard{
ID: ComputeCardID(c.Name, fpath),
SysPrompt: sysPr, SysPrompt: sysPr,
FirstMsg: fm, FirstMsg: fm,
Role: c.Name, Role: c.Name,
@@ -39,7 +44,12 @@ func (c *CharCardSpec) Simplify(userName, fpath string) *CharCard {
} }
} }
func ComputeCardID(role, filePath string) string {
return fmt.Sprintf("%x", md5.Sum([]byte(role+filePath)))
}
type CharCard struct { type CharCard struct {
ID string `json:"id"`
SysPrompt string `json:"sys_prompt"` SysPrompt string `json:"sys_prompt"`
FirstMsg string `json:"first_msg"` FirstMsg string `json:"first_msg"`
Role string `json:"role"` Role string `json:"role"`

13
models/consts.go Normal file
View File

@@ -0,0 +1,13 @@
package models
const (
LoadedMark = "(loaded) "
ToolRespMultyType = "multimodel_content"
)
type APIType int
const (
APITypeChat APIType = iota
APITypeCompletion
)

View File

@@ -5,28 +5,21 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
) )
var (
// imageBaseDir is the base directory for displaying image paths.
// If set, image paths will be shown relative to this directory.
imageBaseDir = ""
)
// SetImageBaseDir sets the base directory for displaying image paths.
// If dir is empty, full paths will be shown.
func SetImageBaseDir(dir string) {
imageBaseDir = dir
}
type FuncCall struct { type FuncCall struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Args map[string]string `json:"args"` Args map[string]string `json:"args"`
} }
type ToolCall struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Args string `json:"arguments"`
}
type LLMResp struct { type LLMResp struct {
Choices []struct { Choices []struct {
FinishReason string `json:"finish_reason"` FinishReason string `json:"finish_reason"`
@@ -108,46 +101,56 @@ 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
ToolCall *ToolCall `json:"tool_call,omitempty"` // For assistant messages with tool calls
IsShellCommand bool `json:"is_shell_command,omitempty"` // True for shell command outputs (always shown)
KnownTo []string `json:"known_to,omitempty"` KnownTo []string `json:"known_to,omitempty"`
Stats *ResponseStats `json:"stats"` Stats *ResponseStats `json:"stats"`
hasContentParts bool // Flag to indicate which content type to marshal HasContentParts bool // Flag to indicate which content type to marshal
} }
// MarshalJSON implements custom JSON marshaling for RoleMsg // MarshalJSON implements custom JSON marshaling for RoleMsg
// //
//nolint:gocritic //nolint:gocritic
func (m RoleMsg) MarshalJSON() ([]byte, error) { func (m RoleMsg) MarshalJSON() ([]byte, error) {
if m.hasContentParts { if m.HasContentParts {
// Use structured content format // Use structured content format
aux := struct { aux := struct {
Role string `json:"role"` Role string `json:"role"`
Content []any `json:"content"` Content []any `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"`
KnownTo []string `json:"known_to,omitempty"` ToolCall *ToolCall `json:"tool_call,omitempty"`
Stats *ResponseStats `json:"stats,omitempty"` IsShellCommand bool `json:"is_shell_command,omitempty"`
KnownTo []string `json:"known_to,omitempty"`
Stats *ResponseStats `json:"stats,omitempty"`
}{ }{
Role: m.Role, Role: m.Role,
Content: m.ContentParts, Content: m.ContentParts,
ToolCallID: m.ToolCallID, ToolCallID: m.ToolCallID,
KnownTo: m.KnownTo, ToolCall: m.ToolCall,
Stats: m.Stats, IsShellCommand: m.IsShellCommand,
KnownTo: m.KnownTo,
Stats: m.Stats,
} }
return json.Marshal(aux) return json.Marshal(aux)
} else { } else {
// Use simple content format // Use simple content format
aux := struct { aux := struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"`
KnownTo []string `json:"known_to,omitempty"` ToolCall *ToolCall `json:"tool_call,omitempty"`
Stats *ResponseStats `json:"stats,omitempty"` IsShellCommand bool `json:"is_shell_command,omitempty"`
KnownTo []string `json:"known_to,omitempty"`
Stats *ResponseStats `json:"stats,omitempty"`
}{ }{
Role: m.Role, Role: m.Role,
Content: m.Content, Content: m.Content,
ToolCallID: m.ToolCallID, ToolCallID: m.ToolCallID,
KnownTo: m.KnownTo, ToolCall: m.ToolCall,
Stats: m.Stats, IsShellCommand: m.IsShellCommand,
KnownTo: m.KnownTo,
Stats: m.Stats,
} }
return json.Marshal(aux) return json.Marshal(aux)
} }
@@ -157,29 +160,35 @@ func (m RoleMsg) MarshalJSON() ([]byte, error) {
func (m *RoleMsg) UnmarshalJSON(data []byte) error { func (m *RoleMsg) UnmarshalJSON(data []byte) error {
// First, try to unmarshal as structured content format // First, try to unmarshal as structured content format
var structured struct { var structured struct {
Role string `json:"role"` Role string `json:"role"`
Content []any `json:"content"` Content []any `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"`
KnownTo []string `json:"known_to,omitempty"` ToolCall *ToolCall `json:"tool_call,omitempty"`
Stats *ResponseStats `json:"stats,omitempty"` IsShellCommand bool `json:"is_shell_command,omitempty"`
KnownTo []string `json:"known_to,omitempty"`
Stats *ResponseStats `json:"stats,omitempty"`
} }
if err := json.Unmarshal(data, &structured); err == nil && len(structured.Content) > 0 { if err := json.Unmarshal(data, &structured); err == nil && len(structured.Content) > 0 {
m.Role = structured.Role m.Role = structured.Role
m.ContentParts = structured.Content m.ContentParts = structured.Content
m.ToolCallID = structured.ToolCallID m.ToolCallID = structured.ToolCallID
m.ToolCall = structured.ToolCall
m.IsShellCommand = structured.IsShellCommand
m.KnownTo = structured.KnownTo m.KnownTo = structured.KnownTo
m.Stats = structured.Stats m.Stats = structured.Stats
m.hasContentParts = true m.HasContentParts = true
return nil return nil
} }
// Otherwise, unmarshal as simple content format // Otherwise, unmarshal as simple content format
var simple struct { var simple struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"`
KnownTo []string `json:"known_to,omitempty"` ToolCall *ToolCall `json:"tool_call,omitempty"`
Stats *ResponseStats `json:"stats,omitempty"` IsShellCommand bool `json:"is_shell_command,omitempty"`
KnownTo []string `json:"known_to,omitempty"`
Stats *ResponseStats `json:"stats,omitempty"`
} }
if err := json.Unmarshal(data, &simple); err != nil { if err := json.Unmarshal(data, &simple); err != nil {
return err return err
@@ -187,79 +196,17 @@ func (m *RoleMsg) UnmarshalJSON(data []byte) error {
m.Role = simple.Role m.Role = simple.Role
m.Content = simple.Content m.Content = simple.Content
m.ToolCallID = simple.ToolCallID m.ToolCallID = simple.ToolCallID
m.ToolCall = simple.ToolCall
m.IsShellCommand = simple.IsShellCommand
m.KnownTo = simple.KnownTo m.KnownTo = simple.KnownTo
m.Stats = simple.Stats m.Stats = simple.Stats
m.hasContentParts = false m.HasContentParts = false
return nil return nil
} }
func (m *RoleMsg) ToText(i int) string {
var contentStr string
var imageIndicators []string
if !m.hasContentParts {
contentStr = m.Content
} else {
var textParts []string
for _, part := range m.ContentParts {
switch p := part.(type) {
case TextContentPart:
if p.Type == "text" {
textParts = append(textParts, p.Text)
}
case ImageContentPart:
displayPath := p.Path
if displayPath == "" {
displayPath = "image"
} else {
displayPath = extractDisplayPath(displayPath)
}
imageIndicators = append(imageIndicators, fmt.Sprintf("[orange::i][image: %s][-:-:-]", displayPath))
case map[string]any:
if partType, exists := p["type"]; exists {
switch partType {
case "text":
if textVal, textExists := p["text"]; textExists {
if textStr, isStr := textVal.(string); isStr {
textParts = append(textParts, textStr)
}
}
case "image_url":
var displayPath string
if pathVal, pathExists := p["path"]; pathExists {
if pathStr, isStr := pathVal.(string); isStr && pathStr != "" {
displayPath = extractDisplayPath(pathStr)
}
}
if displayPath == "" {
displayPath = "image"
}
imageIndicators = append(imageIndicators, fmt.Sprintf("[orange::i][image: %s][-:-:-]", displayPath))
}
}
}
}
contentStr = strings.Join(textParts, " ") + " "
}
contentStr, _ = strings.CutPrefix(contentStr, m.Role+":")
icon := fmt.Sprintf("(%d) <%s>: ", i, m.Role)
var finalContent strings.Builder
if len(imageIndicators) > 0 {
for _, indicator := range imageIndicators {
finalContent.WriteString(indicator)
finalContent.WriteString("\n")
}
}
finalContent.WriteString(contentStr)
if m.Stats != nil {
fmt.Fprintf(&finalContent, "\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())
return strings.ReplaceAll(textMsg, "\n\n", "\n")
}
func (m *RoleMsg) ToPrompt() string { func (m *RoleMsg) ToPrompt() string {
var contentStr string var contentStr string
if !m.hasContentParts { if !m.HasContentParts {
contentStr = m.Content contentStr = m.Content
} else { } else {
// For structured content, just take the text parts // For structured content, just take the text parts
@@ -292,7 +239,7 @@ func NewRoleMsg(role, content string) RoleMsg {
return RoleMsg{ return RoleMsg{
Role: role, Role: role,
Content: content, Content: content,
hasContentParts: false, HasContentParts: false,
} }
} }
@@ -301,7 +248,7 @@ func NewMultimodalMsg(role string, contentParts []any) RoleMsg {
return RoleMsg{ return RoleMsg{
Role: role, Role: role,
ContentParts: contentParts, ContentParts: contentParts,
hasContentParts: true, HasContentParts: true,
} }
} }
@@ -310,7 +257,7 @@ func (m *RoleMsg) HasContent() bool {
if m.Content != "" { if m.Content != "" {
return true return true
} }
if m.hasContentParts && len(m.ContentParts) > 0 { if m.HasContentParts && len(m.ContentParts) > 0 {
return true return true
} }
return false return false
@@ -318,7 +265,7 @@ func (m *RoleMsg) HasContent() bool {
// IsContentParts returns true if the message uses structured content parts // IsContentParts returns true if the message uses structured content parts
func (m *RoleMsg) IsContentParts() bool { func (m *RoleMsg) IsContentParts() bool {
return m.hasContentParts return m.HasContentParts
} }
// GetContentParts returns the content parts of the message // GetContentParts returns the content parts of the message
@@ -335,14 +282,16 @@ func (m *RoleMsg) Copy() RoleMsg {
ToolCallID: m.ToolCallID, ToolCallID: m.ToolCallID,
KnownTo: m.KnownTo, KnownTo: m.KnownTo,
Stats: m.Stats, Stats: m.Stats,
hasContentParts: m.hasContentParts, HasContentParts: m.HasContentParts,
ToolCall: m.ToolCall,
IsShellCommand: m.IsShellCommand,
} }
} }
// GetText returns the text content of the message, handling both // GetText returns the text content of the message, handling both
// simple Content and multimodal ContentParts formats. // simple Content and multimodal ContentParts formats.
func (m *RoleMsg) GetText() string { func (m *RoleMsg) GetText() string {
if !m.hasContentParts { if !m.HasContentParts {
return m.Content return m.Content
} }
var textParts []string var textParts []string
@@ -371,7 +320,7 @@ func (m *RoleMsg) GetText() string {
// ContentParts (multimodal), it updates the text parts while preserving // ContentParts (multimodal), it updates the text parts while preserving
// images. If not, it sets the simple Content field. // images. If not, it sets the simple Content field.
func (m *RoleMsg) SetText(text string) { func (m *RoleMsg) SetText(text string) {
if !m.hasContentParts { if !m.HasContentParts {
m.Content = text m.Content = text
return return
} }
@@ -401,14 +350,14 @@ func (m *RoleMsg) SetText(text string) {
// AddTextPart adds a text content part to the message // AddTextPart adds a text content part to the message
func (m *RoleMsg) AddTextPart(text string) { func (m *RoleMsg) AddTextPart(text string) {
if !m.hasContentParts { if !m.HasContentParts {
// Convert to content parts format // Convert to content parts format
if m.Content != "" { if m.Content != "" {
m.ContentParts = []any{TextContentPart{Type: "text", Text: m.Content}} m.ContentParts = []any{TextContentPart{Type: "text", Text: m.Content}}
} else { } else {
m.ContentParts = []any{} m.ContentParts = []any{}
} }
m.hasContentParts = true m.HasContentParts = true
} }
textPart := TextContentPart{Type: "text", Text: text} textPart := TextContentPart{Type: "text", Text: text}
m.ContentParts = append(m.ContentParts, textPart) m.ContentParts = append(m.ContentParts, textPart)
@@ -416,14 +365,14 @@ func (m *RoleMsg) AddTextPart(text string) {
// AddImagePart adds an image content part to the message // AddImagePart adds an image content part to the message
func (m *RoleMsg) AddImagePart(imageURL, imagePath string) { func (m *RoleMsg) AddImagePart(imageURL, imagePath string) {
if !m.hasContentParts { if !m.HasContentParts {
// Convert to content parts format // Convert to content parts format
if m.Content != "" { if m.Content != "" {
m.ContentParts = []any{TextContentPart{Type: "text", Text: m.Content}} m.ContentParts = []any{TextContentPart{Type: "text", Text: m.Content}}
} else { } else {
m.ContentParts = []any{} m.ContentParts = []any{}
} }
m.hasContentParts = true m.HasContentParts = true
} }
imagePart := ImageContentPart{ imagePart := ImageContentPart{
Type: "image_url", Type: "image_url",
@@ -442,7 +391,6 @@ func CreateImageURLFromPath(imagePath string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
// Determine the image format based on file extension // Determine the image format based on file extension
var mimeType string var mimeType string
switch { switch {
@@ -459,39 +407,12 @@ func CreateImageURLFromPath(imagePath string) (string, error) {
default: default:
mimeType = "image/jpeg" // default mimeType = "image/jpeg" // default
} }
// Encode to base64 // Encode to base64
encoded := base64.StdEncoding.EncodeToString(data) encoded := base64.StdEncoding.EncodeToString(data)
// Create data URL // Create data URL
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil
} }
// extractDisplayPath returns a path suitable for display, potentially relative to imageBaseDir
func extractDisplayPath(p string) string {
if p == "" {
return ""
}
// If base directory is set, try to make path relative to it
if imageBaseDir != "" {
if rel, err := filepath.Rel(imageBaseDir, p); err == nil {
// Check if relative path doesn't start with ".." (meaning it's within base dir)
// If it starts with "..", we might still want to show it as relative
// but for now we show full path if it goes outside base dir
if !strings.HasPrefix(rel, "..") {
p = rel
}
}
}
// Truncate long paths to last 60 characters if needed
if len(p) > 60 {
return "..." + p[len(p)-60:]
}
return p
}
type ChatBody struct { type ChatBody struct {
Model string `json:"model"` Model string `json:"model"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
@@ -499,16 +420,16 @@ type ChatBody struct {
} }
func (cb *ChatBody) Rename(oldname, newname string) { func (cb *ChatBody) Rename(oldname, newname string) {
for i, m := range cb.Messages { for i := range cb.Messages {
cb.Messages[i].Content = strings.ReplaceAll(m.Content, oldname, newname) cb.Messages[i].Content = strings.ReplaceAll(cb.Messages[i].Content, oldname, newname)
cb.Messages[i].Role = strings.ReplaceAll(m.Role, oldname, newname) cb.Messages[i].Role = strings.ReplaceAll(cb.Messages[i].Role, oldname, newname)
} }
} }
func (cb *ChatBody) ListRoles() []string { func (cb *ChatBody) ListRoles() []string {
namesMap := make(map[string]struct{}) namesMap := make(map[string]struct{})
for _, m := range cb.Messages { for i := range cb.Messages {
namesMap[m.Role] = struct{}{} namesMap[cb.Messages[i].Role] = struct{}{}
} }
resp := make([]string, len(namesMap)) resp := make([]string, len(namesMap))
i := 0 i := 0
@@ -595,24 +516,6 @@ type OpenAIReq struct {
// === // ===
// type LLMModels struct {
// Object string `json:"object"`
// Data []struct {
// ID string `json:"id"`
// Object string `json:"object"`
// Created int `json:"created"`
// OwnedBy string `json:"owned_by"`
// Meta struct {
// VocabType int `json:"vocab_type"`
// NVocab int `json:"n_vocab"`
// NCtxTrain int `json:"n_ctx_train"`
// NEmbd int `json:"n_embd"`
// NParams int64 `json:"n_params"`
// Size int64 `json:"size"`
// } `json:"meta"`
// } `json:"data"`
// }
type LlamaCPPReq struct { type LlamaCPPReq struct {
Model string `json:"model"` Model string `json:"model"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
@@ -705,6 +608,20 @@ func (lcp *LCPModels) ListModels() []string {
return resp return resp
} }
func (lcp *LCPModels) HasVision(modelID string) bool {
for _, m := range lcp.Data {
if m.ID == modelID {
args := m.Status.Args
for i := 0; i < len(args)-1; i++ {
if args[i] == "--mmproj" {
return true
}
}
}
}
return false
}
type ResponseStats struct { type ResponseStats struct {
Tokens int Tokens int
Duration float64 Duration float64
@@ -718,9 +635,7 @@ type ChatRoundReq struct {
Resume bool Resume bool
} }
type APIType int type MultimodalToolResp struct {
Type string `json:"type"`
const ( Parts []map[string]string `json:"parts"`
APITypeChat APIType = iota }
APITypeCompletion
)

View File

@@ -1,161 +0,0 @@
package models
import (
"strings"
"testing"
)
func TestRoleMsgToTextWithImages(t *testing.T) {
tests := []struct {
name string
msg RoleMsg
index int
expected string // substring to check
}{
{
name: "text and image",
index: 0,
msg: func() RoleMsg {
msg := NewMultimodalMsg("user", []interface{}{})
msg.AddTextPart("Look at this picture")
msg.AddImagePart("data:image/jpeg;base64,abc123", "/home/user/Pictures/cat.jpg")
return msg
}(),
expected: "[orange::i][image: /home/user/Pictures/cat.jpg][-:-:-]",
},
{
name: "image only",
index: 1,
msg: func() RoleMsg {
msg := NewMultimodalMsg("user", []interface{}{})
msg.AddImagePart("data:image/png;base64,xyz789", "/tmp/screenshot_20250217_123456.png")
return msg
}(),
expected: "[orange::i][image: /tmp/screenshot_20250217_123456.png][-:-:-]",
},
{
name: "long filename truncated",
index: 2,
msg: func() RoleMsg {
msg := NewMultimodalMsg("user", []interface{}{})
msg.AddTextPart("Check this")
msg.AddImagePart("data:image/jpeg;base64,foo", "/very/long/path/to/a/really_long_filename_that_exceeds_forty_characters.jpg")
return msg
}(),
expected: "[orange::i][image: .../to/a/really_long_filename_that_exceeds_forty_characters.jpg][-:-:-]",
},
{
name: "multiple images",
index: 3,
msg: func() RoleMsg {
msg := NewMultimodalMsg("user", []interface{}{})
msg.AddTextPart("Multiple images")
msg.AddImagePart("data:image/jpeg;base64,a", "/path/img1.jpg")
msg.AddImagePart("data:image/png;base64,b", "/path/img2.png")
return msg
}(),
expected: "[orange::i][image: /path/img1.jpg][-:-:-]\n[orange::i][image: /path/img2.png][-:-:-]",
},
{
name: "old format without path",
index: 4,
msg: RoleMsg{
Role: "user",
hasContentParts: true,
ContentParts: []interface{}{
map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": "data:image/jpeg;base64,old",
},
},
},
},
expected: "[orange::i][image: image][-:-:-]",
},
{
name: "old format with path",
index: 5,
msg: RoleMsg{
Role: "user",
hasContentParts: true,
ContentParts: []interface{}{
map[string]interface{}{
"type": "image_url",
"path": "/old/path/photo.jpg",
"image_url": map[string]interface{}{
"url": "data:image/jpeg;base64,old",
},
},
},
},
expected: "[orange::i][image: /old/path/photo.jpg][-:-:-]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.msg.ToText(tt.index)
if !strings.Contains(result, tt.expected) {
t.Errorf("ToText() result does not contain expected indicator\ngot: %s\nwant substring: %s", result, tt.expected)
}
// Ensure the indicator appears before text content
if strings.Contains(tt.expected, "cat.jpg") && strings.Contains(result, "Look at this picture") {
indicatorPos := strings.Index(result, "[orange::i][image: /home/user/Pictures/cat.jpg][-:-:-]")
textPos := strings.Index(result, "Look at this picture")
if indicatorPos == -1 || textPos == -1 || indicatorPos >= textPos {
t.Errorf("image indicator should appear before text")
}
}
})
}
}
func TestExtractDisplayPath(t *testing.T) {
// Save original base dir
originalBaseDir := imageBaseDir
defer func() { imageBaseDir = originalBaseDir }()
tests := []struct {
name string
baseDir string
path string
expected string
}{
{
name: "no base dir shows full path",
baseDir: "",
path: "/home/user/images/cat.jpg",
expected: "/home/user/images/cat.jpg",
},
{
name: "relative path within base dir",
baseDir: "/home/user",
path: "/home/user/images/cat.jpg",
expected: "images/cat.jpg",
},
{
name: "path outside base dir shows full path",
baseDir: "/home/user",
path: "/tmp/test.jpg",
expected: "/tmp/test.jpg",
},
{
name: "same directory",
baseDir: "/home/user/images",
path: "/home/user/images/cat.jpg",
expected: "cat.jpg",
},
{
name: "long path truncated",
baseDir: "",
path: "/very/long/path/to/a/really_long_filename_that_exceeds_sixty_characters_limit_yes_it_is_very_long.jpg",
expected: "..._that_exceeds_sixty_characters_limit_yes_it_is_very_long.jpg",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
imageBaseDir = tt.baseDir
result := extractDisplayPath(tt.path)
if result != tt.expected {
t.Errorf("extractDisplayPath(%q) with baseDir=%q = %q, want %q",
tt.path, tt.baseDir, result, tt.expected)
}
})
}
}

View File

@@ -172,3 +172,16 @@ func (orm *ORModels) ListModels(free bool) []string {
} }
return resp return resp
} }
func (orm *ORModels) HasVision(modelID string) bool {
for i := range orm.Data {
if orm.Data[i].ID == modelID {
for _, mod := range orm.Data[i].Architecture.InputModalities {
if mod == "image" {
return true
}
}
}
}
return false
}

View File

@@ -109,6 +109,12 @@ func ReadCardJson(fname string) (*models.CharCard, error) {
if err := json.Unmarshal(data, &card); err != nil { if err := json.Unmarshal(data, &card); err != nil {
return nil, err return nil, err
} }
if card.FilePath == "" {
card.FilePath = fname
}
if card.ID == "" {
card.ID = models.ComputeCardID(card.Role, card.FilePath)
}
return &card, nil return &card, nil
} }

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"gf-lt/models"
"slices" "slices"
"strings" "strings"
@@ -39,9 +40,7 @@ func showModelSelectionPopup() {
default: default:
message = "No llama.cpp models loaded. Ensure llama.cpp server is running with models." message = "No llama.cpp models loaded. Ensure llama.cpp server is running with models."
} }
if err := notifyUser("Empty list", message); err != nil { showToast("Empty list", message)
logger.Error("failed to send notification", "error", err)
}
return return
} }
// Create a list primitive // Create a list primitive
@@ -51,7 +50,7 @@ func showModelSelectionPopup() {
// Find the current model index to set as selected // Find the current model index to set as selected
currentModelIndex := -1 currentModelIndex := -1
for i, model := range modelList { for i, model := range modelList {
if strings.TrimPrefix(model, "(loaded) ") == chatBody.Model { if strings.TrimPrefix(model, models.LoadedMark) == chatBody.Model {
currentModelIndex = i currentModelIndex = i
} }
modelListWidget.AddItem(model, "", 0, nil) modelListWidget.AddItem(model, "", 0, nil)
@@ -61,7 +60,7 @@ 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) {
modelName := strings.TrimPrefix(mainText, "(loaded) ") modelName := strings.TrimPrefix(mainText, models.LoadedMark)
chatBody.Model = modelName chatBody.Model = modelName
cfg.CurrentModel = chatBody.Model cfg.CurrentModel = chatBody.Model
pages.RemovePage("modelSelectionPopup") pages.RemovePage("modelSelectionPopup")
@@ -118,9 +117,7 @@ func showAPILinkSelectionPopup() {
if len(apiLinks) == 0 { if len(apiLinks) == 0 {
logger.Warn("no API links available for selection") logger.Warn("no API links available for selection")
message := "No API links available. Please configure API links in your config file." message := "No API links available. Please configure API links in your config file."
if err := notifyUser("Empty list", message); err != nil { showToast("Empty list", message)
logger.Error("failed to send notification", "error", err)
}
return return
} }
// Create a list primitive // Create a list primitive
@@ -142,6 +139,7 @@ func showAPILinkSelectionPopup() {
apiListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { apiListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
// Update the API in config // Update the API in config
cfg.CurrentAPI = mainText cfg.CurrentAPI = mainText
// updateToolCapabilities()
// Update model list based on new API // Update model list based on new API
// Helper function to get model list for a given API (same as in props_table.go) // Helper function to get model list for a given API (same as in props_table.go)
getModelListForAPI := func(api string) []string { getModelListForAPI := func(api string) []string {
@@ -159,8 +157,9 @@ func showAPILinkSelectionPopup() {
newModelList := getModelListForAPI(cfg.CurrentAPI) newModelList := getModelListForAPI(cfg.CurrentAPI)
// Ensure chatBody.Model is in the new list; if not, set to first available model // Ensure chatBody.Model is in the new list; if not, set to first available model
if len(newModelList) > 0 && !slices.Contains(newModelList, chatBody.Model) { if len(newModelList) > 0 && !slices.Contains(newModelList, chatBody.Model) {
chatBody.Model = newModelList[0] chatBody.Model = strings.TrimPrefix(newModelList[0], models.LoadedMark)
cfg.CurrentModel = chatBody.Model cfg.CurrentModel = chatBody.Model
updateToolCapabilities()
} }
pages.RemovePage("apiLinkSelectionPopup") pages.RemovePage("apiLinkSelectionPopup")
app.SetFocus(textArea) app.SetFocus(textArea)
@@ -203,9 +202,7 @@ func showUserRoleSelectionPopup() {
if len(roles) == 0 { if len(roles) == 0 {
logger.Warn("no roles available for selection") logger.Warn("no roles available for selection")
message := "No roles available for selection." message := "No roles available for selection."
if err := notifyUser("Empty list", message); err != nil { showToast("Empty list", message)
logger.Error("failed to send notification", "error", err)
}
return return
} }
// Create a list primitive // Create a list primitive
@@ -282,9 +279,7 @@ func showBotRoleSelectionPopup() {
if len(roles) == 0 { if len(roles) == 0 {
logger.Warn("no roles available for selection") logger.Warn("no roles available for selection")
message := "No roles available for selection." message := "No roles available for selection."
if err := notifyUser("Empty list", message); err != nil { showToast("Empty list", message)
logger.Error("failed to send notification", "error", err)
}
return return
} }
// Create a list primitive // Create a list primitive
@@ -403,6 +398,66 @@ func showShellFileCompletionPopup(filter string) {
app.SetFocus(widget) app.SetFocus(widget)
} }
func showTextAreaFileCompletionPopup(filter string) {
baseDir := cfg.FilePickerDir
if baseDir == "" {
baseDir = "."
}
complMatches := scanFiles(baseDir, filter)
if len(complMatches) == 0 {
return
}
if len(complMatches) == 1 {
currentText := textArea.GetText()
atIdx := strings.LastIndex(currentText, "@")
if atIdx >= 0 {
before := currentText[:atIdx]
textArea.SetText(before+complMatches[0], true)
}
return
}
widget := tview.NewList().ShowSecondaryText(false).
SetSelectedBackgroundColor(tcell.ColorGray)
widget.SetTitle("file completion").SetBorder(true)
for _, m := range complMatches {
widget.AddItem(m, "", 0, nil)
}
widget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
currentText := textArea.GetText()
atIdx := strings.LastIndex(currentText, "@")
if atIdx >= 0 {
before := currentText[:atIdx]
textArea.SetText(before+mainText, true)
}
pages.RemovePage("textAreaFileCompletionPopup")
app.SetFocus(textArea)
})
widget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
pages.RemovePage("textAreaFileCompletionPopup")
app.SetFocus(textArea)
return nil
}
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
pages.RemovePage("textAreaFileCompletionPopup")
app.SetFocus(textArea)
return nil
}
return event
})
modal := func(p tview.Primitive, width, height int) tview.Primitive {
return tview.NewFlex().
AddItem(nil, 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(nil, 0, 1, false).
AddItem(p, height, 1, true).
AddItem(nil, 0, 1, false), width, 1, true).
AddItem(nil, 0, 1, false)
}
pages.AddPage("textAreaFileCompletionPopup", modal(widget, 80, 20), true, true)
app.SetFocus(widget)
}
func updateWidgetColors(theme *tview.Theme) { func updateWidgetColors(theme *tview.Theme) {
bgColor := theme.PrimitiveBackgroundColor bgColor := theme.PrimitiveBackgroundColor
fgColor := theme.PrimaryTextColor fgColor := theme.PrimaryTextColor
@@ -449,9 +504,7 @@ func showColorschemeSelectionPopup() {
if len(schemeNames) == 0 { if len(schemeNames) == 0 {
logger.Warn("no colorschemes available for selection") logger.Warn("no colorschemes available for selection")
message := "No colorschemes available." message := "No colorschemes available."
if err := notifyUser("Empty list", message); err != nil { showToast("Empty list", message)
logger.Error("failed to send notification", "error", err)
}
return return
} }
// Create a list primitive // Create a list primitive

View File

@@ -259,9 +259,7 @@ func makePropsTable(props map[string]float32) *tview.Table {
// Handle nil options // Handle nil options
if data.Options == nil { if data.Options == nil {
logger.Error("options list is nil for", "label", label) logger.Error("options list is nil for", "label", label)
if err := notifyUser("Configuration error", "Options list is nil for "+label); err != nil { showToast("Configuration error", "Options list is nil for "+label)
logger.Error("failed to send notification", "error", err)
}
return return
} }
@@ -279,9 +277,7 @@ func makePropsTable(props map[string]float32) *tview.Table {
message = "No llama.cpp models loaded. Ensure llama.cpp server is running with models." message = "No llama.cpp models loaded. Ensure llama.cpp server is running with models."
} }
} }
if err := notifyUser("Empty list", message); err != nil { showToast("Empty list", message)
logger.Error("failed to send notification", "error", err)
}
return return
} }
// Create a list primitive // Create a list primitive

View File

@@ -131,13 +131,18 @@ func loadOldChatOrGetNew() []models.RoleMsg {
chat, err := store.GetLastChat() chat, err := store.GetLastChat()
if err != nil { if err != nil {
logger.Warn("failed to load history chat", "error", err) logger.Warn("failed to load history chat", "error", err)
maxID, err := store.ChatGetMaxID()
if err != nil {
logger.Error("failed to fetch max chat id", "error", err)
}
maxID++
chat := &models.Chat{ chat := &models.Chat{
ID: 0, ID: maxID,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
Agent: cfg.AssistantRole, Agent: cfg.AssistantRole,
} }
chat.Name = fmt.Sprintf("%s_%v", chat.Agent, chat.CreatedAt.Unix()) chat.Name = fmt.Sprintf("%s_%v", chat.Agent, chat.ID)
activeChatName = chat.Name activeChatName = chat.Name
chatMap[chat.Name] = chat chatMap[chat.Name] = chat
return defaultStarter return defaultStarter
@@ -149,10 +154,6 @@ func loadOldChatOrGetNew() []models.RoleMsg {
chatMap[chat.Name] = chat chatMap[chat.Name] = chat
return defaultStarter return defaultStarter
} }
// if chat.Name == "" {
// logger.Warn("empty chat name", "id", chat.ID)
// chat.Name = fmt.Sprintf("%s_%v", chat.Agent, chat.CreatedAt.Unix())
// }
chatMap[chat.Name] = chat chatMap[chat.Name] = chat
activeChatName = chat.Name activeChatName = chat.Name
cfg.AssistantRole = chat.Agent cfg.AssistantRole = chat.Agent
@@ -167,8 +168,3 @@ func copyToClipboard(text string) error {
cmd.Stdin = strings.NewReader(text) cmd.Stdin = strings.NewReader(text)
return cmd.Run() return cmd.Run()
} }
func notifyUser(topic, message string) error {
cmd := exec.Command("notify-send", topic, message)
return cmd.Run()
}

View File

@@ -10,16 +10,18 @@ import (
//go:embed migrations/* //go:embed migrations/*
var migrationsFS embed.FS var migrationsFS embed.FS
func (p *ProviderSQL) Migrate() { func (p *ProviderSQL) Migrate() error {
// Get the embedded filesystem // Get the embedded filesystem
migrationsDir, err := fs.Sub(migrationsFS, "migrations") migrationsDir, err := fs.Sub(migrationsFS, "migrations")
if err != nil { if err != nil {
p.logger.Error("Failed to get embedded migrations directory;", "error", err) p.logger.Error("Failed to get embedded migrations directory;", "error", err)
return fmt.Errorf("failed to get embedded migrations directory: %w", err)
} }
// List all .up.sql files // List all .up.sql files
files, err := migrationsFS.ReadDir("migrations") files, err := migrationsFS.ReadDir("migrations")
if err != nil { if err != nil {
p.logger.Error("Failed to read migrations directory;", "error", err) p.logger.Error("Failed to read migrations directory;", "error", err)
return fmt.Errorf("failed to read migrations directory: %w", err)
} }
// Execute each .up.sql file // Execute each .up.sql file
for _, file := range files { for _, file := range files {
@@ -27,11 +29,12 @@ func (p *ProviderSQL) Migrate() {
err := p.executeMigration(migrationsDir, file.Name()) err := p.executeMigration(migrationsDir, file.Name())
if err != nil { if err != nil {
p.logger.Error("Failed to execute migration %s: %v", file.Name(), err) p.logger.Error("Failed to execute migration %s: %v", file.Name(), err)
panic(err) return fmt.Errorf("failed to execute migration %s: %w", file.Name(), err)
} }
} }
} }
p.logger.Debug("All migrations executed successfully!") p.logger.Debug("All migrations executed successfully!")
return nil
} }
func (p *ProviderSQL) executeMigration(migrationsDir fs.FS, fileName string) error { func (p *ProviderSQL) executeMigration(migrationsDir fs.FS, fileName string) error {

View File

@@ -103,7 +103,10 @@ func NewProviderSQL(dbPath string, logger *slog.Logger) FullRepo {
return nil return nil
} }
p := ProviderSQL{db: db, logger: logger} p := ProviderSQL{db: db, logger: logger}
p.Migrate() if err := p.Migrate(); err != nil {
logger.Error("migration failed, app cannot start", "error", err)
return nil
}
return p return p
} }

View File

@@ -1,6 +1,6 @@
{ {
"sys_prompt": "You are an expert software engineering assistant. Your goal is to help users with coding tasks, debugging, refactoring, and software development.\n\n## Core Principles\n1. **Security First**: Never expose secrets, keys, or credentials. Never commit sensitive data.\n2. **No Git Actions**: You can READ git info (status, log, diff) for context, but NEVER perform git actions (commit, add, push, checkout, reset, rm, etc.). Let the user handle all git operations.\n3. **Explore Before Execute**: Always understand the codebase structure before making changes.\n4. **Follow Conventions**: Match existing code style, patterns, and frameworks used in the project.\n5. **Be Concise**: Minimize output tokens while maintaining quality. Avoid unnecessary explanations.\n\n## Workflow for Complex Tasks\nFor multi-step tasks, ALWAYS use the todo system to track progress:\n\n1. **Create Todo List**: At the start of complex tasks, use `todo_create` to break down work into actionable items.\n2. **Update Progress**: Mark items as `in_progress` when working on them, and `completed` when done.\n3. **Check Status**: Use `todo_read` to review your progress.\n\nExample workflow:\n- User: \"Add user authentication to this app\"\n- You: Create todos: [\"Analyze existing auth structure\", \"Check frameworks in use\", \"Implement auth middleware\", \"Add login endpoints\", \"Test implementation\"]\n\n## Task Execution Flow\n\n### Phase 1: Exploration (Always First)\n- Use `file_list` to understand directory structure (path defaults to FilePickerDir if not specified)\n- Use `file_read` to examine relevant files (paths are relative to FilePickerDir unless starting with `/`)\n- Use `execute_command` with `grep`/`find` to search for patterns\n- Check `README` or documentation files\n- Identify: frameworks, conventions, testing approach\n- **Git reads allowed**: You may use `git status`, `git log`, `git diff` for context, but only to inform your work\n- **Path handling**: Relative paths are resolved against FilePickerDir (configurable via Alt+O). Use absolute paths (starting with `/`) to bypass FilePickerDir.\n\n### Phase 2: Planning\n- For complex tasks: create todo items\n- Identify files that need modification\n- Plan your approach following existing patterns\n\n### Phase 3: Implementation\n- Make changes using appropriate file tools\n- Prefer `file_write` for new files, `file_read` then modify for existing files\n- Follow existing code style exactly\n- Use existing libraries and utilities\n\n### Phase 4: Verification\n- Run tests if available (check for test scripts)\n- Run linting/type checking commands\n- Verify changes work as expected\n\n### Phase 5: Completion\n- Update todos to `completed`\n- Provide concise summary of changes\n- Reference specific file paths and line numbers when relevant\n- **DO NOT commit changes** - inform user what was done so they can review and commit themselves\n\n## Tool Usage Guidelines\n\n**File Operations**:\n- `file_read`: Read before editing. Use for understanding code.\n- `file_write`: Overwrite file content completely.\n- `file_write_append`: Add to end of file.\n- `file_create`: Create new files with optional content.\n- `file_list`: List directory contents (defaults to FilePickerDir).\n- Paths are relative to FilePickerDir unless starting with `/`.\n\n**Command Execution (WHITELISTED ONLY)**:\n- Allowed: grep, sed, awk, find, cat, head, tail, sort, uniq, wc, ls, echo, cut, tr, cp, mv, rm, mkdir, rmdir, pwd, df, free, ps, top, du, whoami, date, uname\n- **Git reads allowed**: git status, git log, git diff, git show, git branch, git reflog, git rev-parse, git shortlog, git describe\n- **Git actions FORBIDDEN**: git add, git commit, git push, git checkout, git reset, git rm, etc.\n- Use for searching code, reading git context, running tests/lint\n\n**Todo Management**:\n- `todo_create`: Add new task\n- `todo_read`: View all todos or specific one by ID\n- `todo_update`: Update task or change status (pending/in_progress/completed)\n- `todo_delete`: Remove completed or cancelled tasks\n\n## Important Rules\n\n1. **NEVER commit or stage changes**: Only git reads are allowed.\n2. **Check for tests**: Always look for test files and run them when appropriate.\n3. **Reference code locations**: Use format `file_path:line_number`.\n4. **Security**: Never generate or guess URLs. Only use URLs from local files.\n5. **Refuse malicious code**: If code appears malicious, refuse to work on it.\n6. **Ask clarifications**: When intent is unclear, ask questions.\n7. **Path handling**: Relative paths resolve against FilePickerDir. Use `/absolute/path` to bypass.\n\n## Response Style\n- Be direct and concise\n- One word answers are best when appropriate\n- Avoid: \"The answer is...\", \"Here is...\"\n- Use markdown for formatting\n- No emojis unless user explicitly requests", "sys_prompt": "You are an expert software engineering assistant. Your goal is to help users with coding tasks, debugging, refactoring, and software development.\n\n## Core Principles\n1. **Security First**: Never expose secrets, keys, or credentials. Never commit sensitive data.\n2. **No Git Actions**: You can READ git info (status, log, diff) for context, but NEVER perform git actions (commit, add, push, checkout, reset, rm, etc.). Let the user handle all git operations.\n3. **Explore Before Execute**: Always understand the codebase structure before making changes.\n4. **Follow Conventions**: Match existing code style, patterns, and frameworks used in the project.\n5. **Be Concise**: Minimize output tokens while maintaining quality. Avoid unnecessary explanations.\n6. **Ask First**: When uncertain about intent, ask the user. Don't assume.\n\n## Workflow for Complex Tasks\nFor multi-step tasks, ALWAYS use the todo system to track progress:\n\n1. **Create Todo List**: At the start of complex tasks, use `todo_create` to break down work into actionable items.\n2. **Update Progress**: Mark items as `in_progress` when working on them, and `completed` when done.\n3. **Check Status**: Use `todo_read` to review your progress.\n\nExample workflow:\n- User: \"Add user authentication to this app\"\n- You: Create todos: [\"Analyze existing auth structure\", \"Check frameworks in use\", \"Implement auth middleware\", \"Add login endpoints\", \"Test implementation\"]\n\n## Task Execution Flow\n\n### Phase 1: Exploration (Always First)\n- Use `file_list` to understand directory structure (path defaults to FilePickerDir if not specified)\n- Use `file_read` to examine relevant files (paths are relative to FilePickerDir unless starting with `/`)\n- Use `execute_command` with `grep`/`find` to search for patterns\n- Check README, Makefile, package.json, or similar for build/test commands\n- Identify: frameworks, conventions, testing approach, lint/typecheck commands\n- **Git reads allowed**: You may use `git status`, `git log`, `git diff` for context, but only to inform your work\n- **Path handling**: Relative paths resolve against FilePickerDir; absolute paths (starting with `/`) bypass it\n\n### Phase 2: Planning\n- For complex tasks: create todo items\n- Identify files that need modification\n- Plan your approach following existing patterns\n\n### Phase 3: Implementation\n- Make changes using appropriate file tools\n- Prefer `file_write` for new files, `file_read` then edit for existing files\n- Follow existing code style exactly\n- Use existing libraries and utilities\n\n### Phase 4: Verification\n- Run tests if available (check for test scripts in README/Makefile)\n- Run linting/type checking commands\n- Verify changes work as expected\n\n### Phase 5: Completion\n- Update todos to `completed`\n- Provide concise summary of changes\n- Reference specific file paths and line numbers when relevant\n- **DO NOT commit changes** - inform user what was done so they can review and commit themselves\n\n## Command Execution\n- Use `execute_command` with a single string containing command and arguments (e.g., `go run main.go`, `ls -la`, `cd /tmp`)\n- Use `cd /path` to change the working directory for file operations",
"role": "CodingAssistant", "role": "CodingAssistant",
"filepath": "sysprompts/coding_assistant.json", "filepath": "sysprompts/coding_assistant.json",
"first_msg": "Hello! I'm your coding assistant. I can help you with software engineering tasks like writing code, debugging, refactoring, and exploring codebases. I work best when you give me specific tasks, and for complex work, I'll create a todo list to track my progress. What would you like to work on?" "first_msg": "Hello! I'm your coding assistant. Give me a specific task and I'll get started. For complex work, I'll track progress with todos."
} }

View File

@@ -147,9 +147,7 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
if err := store.RemoveChat(sc.ID); err != nil { if err := store.RemoveChat(sc.ID); err != nil {
logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name) logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name)
} }
if err := notifyUser("chat deleted", selectedChat+" was deleted"); err != nil { showToast("chat deleted", selectedChat+" was deleted")
logger.Error("failed to send notification", "error", err)
}
// load last chat // load last chat
chatBody.Messages = loadOldChatOrGetNew() chatBody.Messages = loadOldChatOrGetNew()
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
@@ -159,27 +157,16 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
// save updated card // save updated card
fi := strings.Index(selectedChat, "_") fi := strings.Index(selectedChat, "_")
agentName := selectedChat[fi+1:] agentName := selectedChat[fi+1:]
cc, ok := sysMap[agentName] cc := GetCardByRole(agentName)
if !ok { if cc == nil {
logger.Warn("no such card", "agent", agentName) logger.Warn("no such card", "agent", agentName)
//no:lint showToast("error", "no such card: "+agentName)
if err := notifyUser("error", "no such card: "+agentName); err != nil {
logger.Warn("failed ot notify", "error", err)
}
return return
} }
// if chatBody.Messages[0].Role != "system" || chatBody.Messages[1].Role != agentName {
// if err := notifyUser("error", "unexpected chat structure; card: "+agentName); err != nil {
// logger.Warn("failed ot notify", "error", err)
// }
// return
// }
// change sys_prompt + first msg
cc.SysPrompt = chatBody.Messages[0].Content cc.SysPrompt = chatBody.Messages[0].Content
cc.FirstMsg = chatBody.Messages[1].Content cc.FirstMsg = chatBody.Messages[1].Content
if err := pngmeta.WriteToPng(cc.ToSpec(cfg.UserRole), cc.FilePath, cc.FilePath); err != nil { if err := pngmeta.WriteToPng(cc.ToSpec(cfg.UserRole), cc.FilePath, cc.FilePath); err != nil {
logger.Error("failed to write charcard", logger.Error("failed to write charcard", "error", err)
"error", err)
} }
return return
case "move sysprompt onto 1st msg": case "move sysprompt onto 1st msg":
@@ -190,33 +177,29 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
pages.RemovePage(historyPage) pages.RemovePage(historyPage)
return return
case "new_chat_from_card": case "new_chat_from_card":
// Reread card from file and start fresh chat
fi := strings.Index(selectedChat, "_") fi := strings.Index(selectedChat, "_")
agentName := selectedChat[fi+1:] agentName := selectedChat[fi+1:]
cc, ok := sysMap[agentName] cc := GetCardByRole(agentName)
if !ok { if cc == nil {
logger.Warn("no such card", "agent", agentName) logger.Warn("no such card", "agent", agentName)
if err := notifyUser("error", "no such card: "+agentName); err != nil { showToast("error", "no such card: "+agentName)
logger.Warn("failed to notify", "error", err)
}
return return
} }
// Reload card from disk
newCard, err := pngmeta.ReadCard(cc.FilePath, cfg.UserRole) newCard, err := pngmeta.ReadCard(cc.FilePath, cfg.UserRole)
if err != nil { if err != nil {
logger.Error("failed to reload charcard", "path", cc.FilePath, "error", err) logger.Error("failed to reload charcard", "path", cc.FilePath, "error", err)
newCard, err = pngmeta.ReadCardJson(cc.FilePath) newCard, err = pngmeta.ReadCardJson(cc.FilePath)
if err != nil { if err != nil {
logger.Error("failed to reload charcard", "path", cc.FilePath, "error", err) logger.Error("failed to reload charcard", "path", cc.FilePath, "error", err)
if err := notifyUser("error", "failed to reload card: "+cc.FilePath); err != nil { showToast("error", "failed to reload card: "+cc.FilePath)
logger.Warn("failed to notify", "error", err)
}
return return
} }
} }
// Update sysMap with fresh card data if newCard.ID == "" {
sysMap[agentName] = newCard newCard.ID = models.ComputeCardID(newCard.Role, newCard.FilePath)
// fetching sysprompt and first message anew from the card }
sysMap[newCard.ID] = newCard
roleToID[newCard.Role] = newCard.ID
startNewChat(false) startNewChat(false)
pages.RemovePage(historyPage) pages.RemovePage(historyPage)
return return
@@ -457,13 +440,13 @@ func makeRAGTable(fileList []string, loadedFiles []string) *tview.Flex {
go func() { go func() {
if err := ragger.LoadRAG(fpath); err != nil { if err := ragger.LoadRAG(fpath); err != nil {
logger.Error("failed to embed file", "chat", fpath, "error", err) logger.Error("failed to embed file", "chat", fpath, "error", err)
_ = notifyUser("RAG", "failed to embed file; error: "+err.Error()) showToast("RAG", "failed to embed file; error: "+err.Error())
app.QueueUpdate(func() { app.QueueUpdate(func() {
pages.RemovePage(RAGPage) pages.RemovePage(RAGPage)
}) })
return return
} }
_ = notifyUser("RAG", "file loaded successfully") showToast("RAG", "file loaded successfully")
app.QueueUpdate(func() { app.QueueUpdate(func() {
pages.RemovePage(RAGPage) pages.RemovePage(RAGPage)
}) })
@@ -474,13 +457,13 @@ func makeRAGTable(fileList []string, loadedFiles []string) *tview.Flex {
go func() { go func() {
if err := ragger.RemoveFile(f.name); err != nil { if err := ragger.RemoveFile(f.name); err != nil {
logger.Error("failed to unload file from RAG", "filename", f.name, "error", err) logger.Error("failed to unload file from RAG", "filename", f.name, "error", err)
_ = notifyUser("RAG", "failed to unload file; error: "+err.Error()) showToast("RAG", "failed to unload file; error: "+err.Error())
app.QueueUpdate(func() { app.QueueUpdate(func() {
pages.RemovePage(RAGPage) pages.RemovePage(RAGPage)
}) })
return return
} }
_ = notifyUser("RAG", "file unloaded successfully") showToast("RAG", "file unloaded successfully")
app.QueueUpdate(func() { app.QueueUpdate(func() {
pages.RemovePage(RAGPage) pages.RemovePage(RAGPage)
}) })
@@ -492,9 +475,7 @@ func makeRAGTable(fileList []string, loadedFiles []string) *tview.Flex {
logger.Error("failed to delete file", "filename", fpath, "error", err) logger.Error("failed to delete file", "filename", fpath, "error", err)
return return
} }
if err := notifyUser("chat deleted", fpath+" was deleted"); err != nil { showToast("chat deleted", fpath+" was deleted")
logger.Error("failed to send notification", "error", err)
}
return return
default: default:
pages.RemovePage(RAGPage) pages.RemovePage(RAGPage)
@@ -529,8 +510,8 @@ func makeAgentTable(agentList []string) *tview.Table {
SetSelectable(false)) SetSelectable(false))
case 1: case 1:
if actions[c-1] == "filepath" { if actions[c-1] == "filepath" {
cc, ok := sysMap[agentList[r]] cc := GetCardByRole(agentList[r])
if !ok { if cc == nil {
continue continue
} }
chatActTable.SetCell(r, c, chatActTable.SetCell(r, c,
@@ -603,9 +584,7 @@ func makeAgentTable(agentList []string) *tview.Table {
if err := store.RemoveChat(sc.ID); err != nil { if err := store.RemoveChat(sc.ID); err != nil {
logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name) logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name)
} }
if err := notifyUser("chat deleted", selected+" was deleted"); err != nil { showToast("chat deleted", selected+" was deleted")
logger.Error("failed to send notification", "error", err)
}
pages.RemovePage(agentPage) pages.RemovePage(agentPage)
return return
default: default:
@@ -676,13 +655,9 @@ func makeCodeBlockTable(codeBlocks []string) *tview.Table {
switch tc.Text { switch tc.Text {
case "copy": case "copy":
if err := copyToClipboard(selected); err != nil { if err := copyToClipboard(selected); err != nil {
if err := notifyUser("error", err.Error()); err != nil { showToast("error", err.Error())
logger.Error("failed to send notification", "error", err)
}
}
if err := notifyUser("copied", selected); err != nil {
logger.Error("failed to send notification", "error", err)
} }
showToast("copied", selected)
pages.RemovePage(codeBlockPage) pages.RemovePage(codeBlockPage)
app.SetFocus(textArea) app.SetFocus(textArea)
return return
@@ -775,9 +750,7 @@ func makeImportChatTable(filenames []string) *tview.Table {
if err := store.RemoveChat(sc.ID); err != nil { if err := store.RemoveChat(sc.ID); err != nil {
logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name) logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name)
} }
if err := notifyUser("chat deleted", selected+" was deleted"); err != nil { showToast("chat deleted", selected+" was deleted")
logger.Error("failed to send notification", "error", err)
}
pages.RemovePage(historyPage) pages.RemovePage(historyPage)
return return
default: default:

1027
tools.go

File diff suppressed because it is too large Load Diff

653
tools_playwright.go Normal file
View File

@@ -0,0 +1,653 @@
package main
import (
"encoding/json"
"fmt"
"gf-lt/models"
"os"
"strconv"
"strings"
"sync"
"github.com/playwright-community/playwright-go"
)
var browserToolSysMsg = `
Additional browser automation tools (Playwright):
[
{
"name": "pw_start",
"args": [],
"when_to_use": "start a browser instance before doing any browser automation. Must be called first."
},
{
"name": "pw_stop",
"args": [],
"when_to_use": "stop the browser instance when done with automation."
},
{
"name": "pw_is_running",
"args": [],
"when_to_use": "check if browser is currently running."
},
{
"name": "pw_navigate",
"args": ["url"],
"when_to_use": "open a specific URL in the web browser."
},
{
"name": "pw_click",
"args": ["selector", "index"],
"when_to_use": "click on an element on the current webpage. Use 'index' for multiple matches (default 0)."
},
{
"name": "pw_fill",
"args": ["selector", "text", "index"],
"when_to_use": "type text into an input field. Use 'index' for multiple matches (default 0)."
},
{
"name": "pw_extract_text",
"args": ["selector"],
"when_to_use": "extract text content from the page or specific elements. Use selector 'body' for all page text."
},
{
"name": "pw_screenshot",
"args": ["selector", "full_page"],
"when_to_use": "take a screenshot of the page or a specific element. Returns a file path to the image. Use to verify actions or inspect visual state."
},
{
"name": "pw_screenshot_and_view",
"args": ["selector", "full_page"],
"when_to_use": "take a screenshot and return the image for viewing. Use to visually verify page state."
},
{
"name": "pw_wait_for_selector",
"args": ["selector", "timeout"],
"when_to_use": "wait for an element to appear on the page before proceeding with further actions."
},
{
"name": "pw_drag",
"args": ["x1", "y1", "x2", "y2"],
"when_to_use": "drag the mouse from point (x1,y1) to (x2,y2)."
},
{
"name": "pw_click_at",
"args": ["x", "y"],
"when_to_use": "click at specific X,Y coordinates on the page. Use when you know the exact position."
},
{
"name": "pw_get_html",
"args": ["selector"],
"when_to_use": "get the HTML content of the page or a specific element. Use to understand page structure or extract raw HTML."
},
{
"name": "pw_get_dom",
"args": ["selector"],
"when_to_use": "get a structured DOM representation with tag, attributes, text, and children. Use to inspect element hierarchy and properties."
},
{
"name": "pw_search_elements",
"args": ["text", "selector"],
"when_to_use": "search for elements by text content or CSS selector. Returns matching elements with their tags, text, and HTML."
}
]
`
var (
pw *playwright.Playwright
browser playwright.Browser
browserStarted bool
browserStartMu sync.Mutex
page playwright.Page
)
func pwShutDown() error {
if pw == nil {
return nil
}
pwStop(nil)
return pw.Stop()
}
func installPW() error {
err := playwright.Install(&playwright.RunOptions{Verbose: false})
if err != nil {
logger.Warn("playwright not available", "error", err)
return err
}
return nil
}
func checkPlaywright() error {
var err error
pw, err = playwright.Run()
if err != nil {
logger.Warn("playwright not available", "error", err)
return err
}
return nil
}
func pwStart(args map[string]string) []byte {
browserStartMu.Lock()
defer browserStartMu.Unlock()
if browserStarted {
return []byte(`{"error": "Browser already started"}`)
}
var err error
browser, err = pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
Headless: playwright.Bool(!cfg.PlaywrightDebug),
})
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to launch browser: %s"}`, err.Error()))
}
page, err = browser.NewPage()
if err != nil {
browser.Close()
return []byte(fmt.Sprintf(`{"error": "failed to create page: %s"}`, err.Error()))
}
browserStarted = true
return []byte(`{"success": true, "message": "Browser started"}`)
}
func pwStop(args map[string]string) []byte {
browserStartMu.Lock()
defer browserStartMu.Unlock()
if !browserStarted {
return []byte(`{"success": true, "message": "Browser was not running"}`)
}
if page != nil {
page.Close()
page = nil
}
if browser != nil {
browser.Close()
browser = nil
}
browserStarted = false
return []byte(`{"success": true, "message": "Browser stopped"}`)
}
func pwIsRunning(args map[string]string) []byte {
if browserStarted {
return []byte(`{"running": true, "message": "Browser is running"}`)
}
return []byte(`{"running": false, "message": "Browser is not running"}`)
}
func pwNavigate(args map[string]string) []byte {
url, ok := args["url"]
if !ok || url == "" {
return []byte(`{"error": "url not provided"}`)
}
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
_, err := page.Goto(url)
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to navigate: %s"}`, err.Error()))
}
title, _ := page.Title()
pageURL := page.URL()
return []byte(fmt.Sprintf(`{"success": true, "title": "%s", "url": "%s"}`, title, pageURL))
}
func pwClick(args map[string]string) []byte {
selector, ok := args["selector"]
if !ok || selector == "" {
return []byte(`{"error": "selector not provided"}`)
}
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
index := 0
if args["index"] != "" {
if i, err := strconv.Atoi(args["index"]); err != nil {
logger.Warn("failed to parse index", "value", args["index"], "error", err)
} else {
index = i
}
}
locator := page.Locator(selector)
count, err := locator.Count()
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to find elements: %s"}`, err.Error()))
}
if index >= count {
return []byte(fmt.Sprintf(`{"error": "Element not found at index %d (found %d elements)"}`, index, count))
}
err = locator.Nth(index).Click()
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to click: %s"}`, err.Error()))
}
return []byte(`{"success": true, "message": "Clicked element"}`)
}
func pwFill(args map[string]string) []byte {
selector, ok := args["selector"]
if !ok || selector == "" {
return []byte(`{"error": "selector not provided"}`)
}
text := args["text"]
if text == "" {
text = ""
}
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
index := 0
if args["index"] != "" {
if i, err := strconv.Atoi(args["index"]); err != nil {
logger.Warn("failed to parse index", "value", args["index"], "error", err)
} else {
index = i
}
}
locator := page.Locator(selector)
count, err := locator.Count()
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to find elements: %s"}`, err.Error()))
}
if index >= count {
return []byte(fmt.Sprintf(`{"error": "Element not found at index %d"}`, index))
}
err = locator.Nth(index).Fill(text)
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to fill: %s"}`, err.Error()))
}
return []byte(`{"success": true, "message": "Filled input"}`)
}
func pwExtractText(args map[string]string) []byte {
selector := args["selector"]
if selector == "" {
selector = "body"
}
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
locator := page.Locator(selector)
count, err := locator.Count()
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to find elements: %s"}`, err.Error()))
}
if count == 0 {
return []byte(`{"error": "No elements found"}`)
}
if selector == "body" {
text, err := page.Locator("body").TextContent()
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to get text: %s"}`, err.Error()))
}
return []byte(fmt.Sprintf(`{"text": "%s"}`, text))
}
var texts []string
for i := 0; i < count; i++ {
text, err := locator.Nth(i).TextContent()
if err != nil {
continue
}
texts = append(texts, text)
}
return []byte(fmt.Sprintf(`{"text": "%s"}`, joinLines(texts)))
}
func joinLines(lines []string) string {
var sb strings.Builder
for i, line := range lines {
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(line)
}
return sb.String()
}
func pwScreenshot(args map[string]string) []byte {
selector := args["selector"]
fullPage := args["full_page"] == "true"
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
path := fmt.Sprintf("/tmp/pw_screenshot_%d.png", os.Getpid())
var err error
if selector != "" && selector != "body" {
locator := page.Locator(selector)
_, err = locator.Screenshot(playwright.LocatorScreenshotOptions{
Path: playwright.String(path),
})
} else {
_, err = page.Screenshot(playwright.PageScreenshotOptions{
Path: playwright.String(path),
FullPage: playwright.Bool(fullPage),
})
}
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to take screenshot: %s"}`, err.Error()))
}
return []byte(fmt.Sprintf(`{"path": "%s"}`, path))
}
func pwScreenshotAndView(args map[string]string) []byte {
selector := args["selector"]
fullPage := args["full_page"] == "true"
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
path := fmt.Sprintf("/tmp/pw_screenshot_%d.png", os.Getpid())
var err error
if selector != "" && selector != "body" {
locator := page.Locator(selector)
_, err = locator.Screenshot(playwright.LocatorScreenshotOptions{
Path: playwright.String(path),
})
} else {
_, err = page.Screenshot(playwright.PageScreenshotOptions{
Path: playwright.String(path),
FullPage: playwright.Bool(fullPage),
})
}
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to take screenshot: %s"}`, err.Error()))
}
dataURL, err := models.CreateImageURLFromPath(path)
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to create image URL: %s"}`, err.Error()))
}
resp := models.MultimodalToolResp{
Type: "multimodal_content",
Parts: []map[string]string{
{"type": "text", "text": "Screenshot saved: " + path},
{"type": "image_url", "url": dataURL},
},
}
jsonResult, err := json.Marshal(resp)
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to marshal result: %s"}`, err.Error()))
}
return jsonResult
}
func pwWaitForSelector(args map[string]string) []byte {
selector, ok := args["selector"]
if !ok || selector == "" {
return []byte(`{"error": "selector not provided"}`)
}
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
timeout := 30000
if args["timeout"] != "" {
if t, err := strconv.Atoi(args["timeout"]); err != nil {
logger.Warn("failed to parse timeout", "value", args["timeout"], "error", err)
} else {
timeout = t
}
}
locator := page.Locator(selector)
err := locator.WaitFor(playwright.LocatorWaitForOptions{
Timeout: playwright.Float(float64(timeout)),
})
if err != nil {
return []byte(fmt.Sprintf(`{"error": "element not found: %s"}`, err.Error()))
}
return []byte(`{"success": true, "message": "Element found"}`)
}
func pwDrag(args map[string]string) []byte {
x1, ok := args["x1"]
if !ok {
return []byte(`{"error": "x1 not provided"}`)
}
y1, ok := args["y1"]
if !ok {
return []byte(`{"error": "y1 not provided"}`)
}
x2, ok := args["x2"]
if !ok {
return []byte(`{"error": "x2 not provided"}`)
}
y2, ok := args["y2"]
if !ok {
return []byte(`{"error": "y2 not provided"}`)
}
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
var fx1, fy1, fx2, fy2 float64
if parsedX1, err := strconv.ParseFloat(x1, 64); err != nil {
logger.Warn("failed to parse x1", "value", x1, "error", err)
} else {
fx1 = parsedX1
}
if parsedY1, err := strconv.ParseFloat(y1, 64); err != nil {
logger.Warn("failed to parse y1", "value", y1, "error", err)
} else {
fy1 = parsedY1
}
if parsedX2, err := strconv.ParseFloat(x2, 64); err != nil {
logger.Warn("failed to parse x2", "value", x2, "error", err)
} else {
fx2 = parsedX2
}
if parsedY2, err := strconv.ParseFloat(y2, 64); err != nil {
logger.Warn("failed to parse y2", "value", y2, "error", err)
} else {
fy2 = parsedY2
}
mouse := page.Mouse()
err := mouse.Move(fx1, fy1)
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to move mouse: %s"}`, err.Error()))
}
err = mouse.Down()
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to mouse down: %s"}`, err.Error()))
}
err = mouse.Move(fx2, fy2)
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to move mouse: %s"}`, err.Error()))
}
err = mouse.Up()
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to mouse up: %s"}`, err.Error()))
}
return []byte(fmt.Sprintf(`{"success": true, "message": "Dragged from (%s,%s) to (%s,%s)"}`, x1, y1, x2, y2))
}
func pwClickAt(args map[string]string) []byte {
x, ok := args["x"]
if !ok {
return []byte(`{"error": "x not provided"}`)
}
y, ok := args["y"]
if !ok {
return []byte(`{"error": "y not provided"}`)
}
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
fx, err := strconv.ParseFloat(x, 64)
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to parse x: %s"}`, err.Error()))
}
fy, err := strconv.ParseFloat(y, 64)
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to parse y: %s"}`, err.Error()))
}
mouse := page.Mouse()
err = mouse.Click(fx, fy)
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to click: %s"}`, err.Error()))
}
return []byte(fmt.Sprintf(`{"success": true, "message": "Clicked at (%s,%s)"}`, x, y))
}
func pwGetHTML(args map[string]string) []byte {
selector := args["selector"]
if selector == "" {
selector = "body"
}
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
locator := page.Locator(selector)
count, err := locator.Count()
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to find elements: %s"}`, err.Error()))
}
if count == 0 {
return []byte(`{"error": "No elements found"}`)
}
html, err := locator.First().InnerHTML()
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to get HTML: %s"}`, err.Error()))
}
return []byte(fmt.Sprintf(`{"html": %s}`, jsonString(html)))
}
type DOMElement struct {
Tag string `json:"tag,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
Text string `json:"text,omitempty"`
Children []DOMElement `json:"children,omitempty"`
Selector string `json:"selector,omitempty"`
InnerHTML string `json:"innerHTML,omitempty"`
}
func buildDOMTree(locator playwright.Locator) ([]DOMElement, error) {
var results []DOMElement
count, err := locator.Count()
if err != nil {
return nil, err
}
for i := 0; i < count; i++ {
el := locator.Nth(i)
dom, err := elementToDOM(el)
if err != nil {
continue
}
results = append(results, dom)
}
return results, nil
}
func elementToDOM(el playwright.Locator) (DOMElement, error) {
dom := DOMElement{}
tag, err := el.Evaluate(`el => el.nodeName`, nil)
if err == nil {
dom.Tag = strings.ToLower(fmt.Sprintf("%v", tag))
}
attributes := make(map[string]string)
attrs, err := el.Evaluate(`el => {
let attrs = {};
for (let i = 0; i < el.attributes.length; i++) {
let attr = el.attributes[i];
attrs[attr.name] = attr.value;
}
return attrs;
}`, nil)
if err == nil {
if amap, ok := attrs.(map[string]any); ok {
for k, v := range amap {
if vs, ok := v.(string); ok {
attributes[k] = vs
}
}
}
}
if len(attributes) > 0 {
dom.Attributes = attributes
}
text, err := el.TextContent()
if err == nil && text != "" {
dom.Text = text
}
innerHTML, err := el.InnerHTML()
if err == nil && innerHTML != "" {
dom.InnerHTML = innerHTML
}
childCount, _ := el.Count()
if childCount > 0 {
childrenLocator := el.Locator("*")
children, err := buildDOMTree(childrenLocator)
if err == nil && len(children) > 0 {
dom.Children = children
}
}
return dom, nil
}
func pwGetDOM(args map[string]string) []byte {
selector := args["selector"]
if selector == "" {
selector = "body"
}
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
locator := page.Locator(selector)
count, err := locator.Count()
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to find elements: %s"}`, err.Error()))
}
if count == 0 {
return []byte(`{"error": "No elements found"}`)
}
dom, err := elementToDOM(locator.First())
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to get DOM: %s"}`, err.Error()))
}
data, err := json.Marshal(dom)
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to marshal DOM: %s"}`, err.Error()))
}
return []byte(fmt.Sprintf(`{"dom": %s}`, string(data)))
}
func pwSearchElements(args map[string]string) []byte {
text := args["text"]
selector := args["selector"]
if text == "" && selector == "" {
return []byte(`{"error": "text or selector not provided"}`)
}
if !browserStarted || page == nil {
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
}
var locator playwright.Locator
if text != "" {
locator = page.GetByText(text)
} else {
locator = page.Locator(selector)
}
count, err := locator.Count()
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to search elements: %s"}`, err.Error()))
}
if count == 0 {
return []byte(`{"elements": []}`)
}
var results []map[string]string
for i := 0; i < count; i++ {
el := locator.Nth(i)
tag, _ := el.Evaluate(`el => el.nodeName`, nil)
text, _ := el.TextContent()
html, _ := el.InnerHTML()
results = append(results, map[string]string{
"index": strconv.Itoa(i),
"tag": strings.ToLower(fmt.Sprintf("%v", tag)),
"text": text,
"html": html,
})
}
data, err := json.Marshal(results)
if err != nil {
return []byte(fmt.Sprintf(`{"error": "failed to marshal results: %s"}`, err.Error()))
}
return []byte(fmt.Sprintf(`{"elements": %s}`, string(data)))
}
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}

291
tui.go
View File

@@ -10,6 +10,7 @@ import (
"path" "path"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
@@ -21,7 +22,6 @@ func isFullScreenPageActive() bool {
} }
var ( var (
app *tview.Application
pages *tview.Pages pages *tview.Pages
textArea *tview.TextArea textArea *tview.TextArea
editArea *tview.TextArea editArea *tview.TextArea
@@ -35,6 +35,8 @@ var (
renameWindow *tview.InputField renameWindow *tview.InputField
roleEditWindow *tview.InputField roleEditWindow *tview.InputField
shellInput *tview.InputField shellInput *tview.InputField
confirmModal *tview.Modal
confirmPageName = "confirm"
fullscreenMode bool fullscreenMode bool
positionVisible bool = true positionVisible bool = true
scrollToEndEnabled bool = true scrollToEndEnabled bool = true
@@ -80,7 +82,7 @@ var (
[yellow]Ctrl+p[white]: props edit form (min-p, dry, etc.) [yellow]Ctrl+p[white]: props edit form (min-p, dry, etc.)
[yellow]Ctrl+v[white]: show API link selection popup to choose current API [yellow]Ctrl+v[white]: show API link selection popup to choose current API
[yellow]Ctrl+r[white]: start/stop recording from your microphone (needs stt server or whisper binary) [yellow]Ctrl+r[white]: start/stop recording from your microphone (needs stt server or whisper binary)
[yellow]Ctrl+t[white]: remove thinking (<think>) and tool messages from context (delete from chat) [yellow]Ctrl+t[white]: (un)collapse tool messages
[yellow]Ctrl+l[white]: show model selection popup to choose current model [yellow]Ctrl+l[white]: show model selection popup to choose current model
[yellow]Ctrl+k[white]: switch tool use (recommend tool use to llm after user msg) [yellow]Ctrl+k[white]: switch tool use (recommend tool use to llm after user msg)
[yellow]Ctrl+a[white]: interrupt tts (needs tts server) [yellow]Ctrl+a[white]: interrupt tts (needs tts server)
@@ -99,6 +101,7 @@ var (
[yellow]Alt+8[white]: show char img or last picked img [yellow]Alt+8[white]: show char img or last picked img
[yellow]Alt+9[white]: warm up (load) selected llama.cpp model [yellow]Alt+9[white]: warm up (load) selected llama.cpp model
[yellow]Alt+t[white]: toggle thinking blocks visibility (collapse/expand <think> blocks) [yellow]Alt+t[white]: toggle thinking blocks visibility (collapse/expand <think> blocks)
[yellow]Ctrl+t[white]: toggle tool call/response visibility (collapse/expand tool calls and non-shell tool responses)
[yellow]Alt+i[white]: show colorscheme selection popup [yellow]Alt+i[white]: show colorscheme selection popup
=== scrolling chat window (some keys similar to vim) === === scrolling chat window (some keys similar to vim) ===
@@ -134,6 +137,55 @@ func setShellMode(enabled bool) {
}() }()
} }
// showToast displays a temporary message in the topright corner.
// It autohides after 3 seconds and disappears when clicked.
func showToast(title, message string) {
sanitize := func(s string, maxLen int) string {
sanitized := strings.Map(func(r rune) rune {
if r < 32 && r != '\t' {
return -1
}
return r
}, s)
if len(sanitized) > maxLen {
sanitized = sanitized[:maxLen-3] + "..."
}
return sanitized
}
title = sanitize(title, 50)
message = sanitize(message, 197)
notification := tview.NewTextView().
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
SetRegions(true).
SetText(fmt.Sprintf("[yellow]%s[-]\n", message)).
SetChangedFunc(func() {
app.Draw()
})
notification.SetTitleAlign(tview.AlignLeft).
SetBorder(true).
SetTitle(title)
// Wrap it in a fullscreen Flex to position it in the topright corner.
// Outer Flex (row) pushes content to the top; inner Flex (column) pushes to the right.
background := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(nil, 0, 1, false). // top spacer
AddItem(tview.NewFlex().SetDirection(tview.FlexColumn).
AddItem(nil, 0, 1, false). // left spacer
AddItem(notification, 40, 1, true), // notification width 40
5, 1, false) // notification height 5
// Generate a unique page name (e.g., using timestamp) to allow multiple toasts.
pageName := fmt.Sprintf("toast-%d", time.Now().UnixNano())
pages.AddPage(pageName, background, true, true)
// Autodismiss after 3 seconds.
time.AfterFunc(3*time.Second, func() {
app.QueueUpdateDraw(func() {
if pages.HasPage(pageName) {
pages.RemovePage(pageName)
}
})
})
}
func init() { func init() {
// Start background goroutine to update model color cache // Start background goroutine to update model color cache
startModelColorUpdater() startModelColorUpdater()
@@ -194,6 +246,39 @@ func init() {
} }
return event return event
}) })
confirmModal = tview.NewModal().
SetText("You are trying to send an empty message.\nIt makes sense if the last message in the chat is from you.\nAre you sure?").
AddButtons([]string{"Yes", "No"}).
SetButtonBackgroundColor(tcell.ColorBlack).
SetButtonTextColor(tcell.ColorWhite).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonLabel == "Yes" {
persona := cfg.UserRole
if cfg.WriteNextMsgAs != "" {
persona = cfg.WriteNextMsgAs
}
chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: ""}
} // In both Yes and No, go back to the main page
pages.SwitchToPage("main") // or whatever your main page is named
})
confirmModal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyRune {
switch event.Rune() {
case 'y', 'Y':
persona := cfg.UserRole
if cfg.WriteNextMsgAs != "" {
persona = cfg.WriteNextMsgAs
}
chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: ""}
pages.SwitchToPage("main")
return nil
case 'n', 'N', 'x', 'X':
pages.SwitchToPage("main")
return nil
}
}
return event
})
textArea = tview.NewTextArea(). textArea = tview.NewTextArea().
SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message.") SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message.")
textArea.SetBorder(true).SetTitle("input") textArea.SetBorder(true).SetTitle("input")
@@ -291,9 +376,7 @@ func init() {
defer colorText() defer colorText()
editedMsg := editArea.GetText() editedMsg := editArea.GetText()
if editedMsg == "" { if editedMsg == "" {
if err := notifyUser("edit", "no edit provided"); err != nil { showToast("edit", "no edit provided")
logger.Error("failed to send notification", "error", err)
}
pages.RemovePage(editMsgPage) pages.RemovePage(editMsgPage)
return nil return nil
} }
@@ -323,9 +406,7 @@ func init() {
case tcell.KeyEnter: case tcell.KeyEnter:
newRole := roleEditWindow.GetText() newRole := roleEditWindow.GetText()
if newRole == "" { if newRole == "" {
if err := notifyUser("edit", "no role provided"); err != nil { showToast("edit", "no role provided")
logger.Error("failed to send notification", "error", err)
}
pages.RemovePage(roleEditPage) pages.RemovePage(roleEditPage)
return return
} }
@@ -352,9 +433,7 @@ func init() {
siInt, err := strconv.Atoi(si) siInt, err := strconv.Atoi(si)
if err != nil { if err != nil {
logger.Error("failed to convert provided index", "error", err, "si", si) logger.Error("failed to convert provided index", "error", err, "si", si)
if err := notifyUser("cancel", "no index provided, copying user input"); err != nil { showToast("cancel", "no index provided, copying user input")
logger.Error("failed to send notification", "error", err)
}
if err := copyToClipboard(textArea.GetText()); err != nil { if err := copyToClipboard(textArea.GetText()); err != nil {
logger.Error("failed to copy to clipboard", "error", err) logger.Error("failed to copy to clipboard", "error", err)
} }
@@ -365,9 +444,7 @@ func init() {
if len(chatBody.Messages)-1 < selectedIndex || selectedIndex < 0 { if len(chatBody.Messages)-1 < selectedIndex || selectedIndex < 0 {
msg := "chosen index is out of bounds, will copy user input" msg := "chosen index is out of bounds, will copy user input"
logger.Warn(msg, "index", selectedIndex) logger.Warn(msg, "index", selectedIndex)
if err := notifyUser("error", msg); err != nil { showToast("error", msg)
logger.Error("failed to send notification", "error", err)
}
if err := copyToClipboard(textArea.GetText()); err != nil { if err := copyToClipboard(textArea.GetText()); err != nil {
logger.Error("failed to copy to clipboard", "error", err) logger.Error("failed to copy to clipboard", "error", err)
} }
@@ -393,9 +470,7 @@ func init() {
} }
previewLen := min(30, len(msgText)) previewLen := min(30, len(msgText))
notification := fmt.Sprintf("msg '%s' was copied to the clipboard", msgText[:previewLen]) notification := fmt.Sprintf("msg '%s' was copied to the clipboard", msgText[:previewLen])
if err := notifyUser("copied", notification); err != nil { showToast("copied", notification)
logger.Error("failed to send notification", "error", err)
}
hideIndexBar() // Hide overlay after copying hideIndexBar() // Hide overlay after copying
} }
return nil return nil
@@ -427,9 +502,7 @@ func init() {
logger.Error("failed to upsert chat", "error", err, "chat", currentChat) logger.Error("failed to upsert chat", "error", err, "chat", currentChat)
} }
notification := fmt.Sprintf("renamed chat to '%s'", activeChatName) notification := fmt.Sprintf("renamed chat to '%s'", activeChatName)
if err := notifyUser("renamed", notification); err != nil { showToast("renamed", notification)
logger.Error("failed to send notification", "error", err)
}
} }
return event return event
}) })
@@ -539,9 +612,7 @@ func init() {
if scrollToEndEnabled { if scrollToEndEnabled {
status = "enabled" status = "enabled"
} }
if err := notifyUser("autoscroll", "Auto-scrolling "+status); err != nil { showToast("autoscroll", "Auto-scrolling "+status)
logger.Error("failed to send notification", "error", err)
}
updateStatusLine() updateStatusLine()
} }
// Handle Alt+7 to toggle injectRole // Handle Alt+7 to toggle injectRole
@@ -558,9 +629,19 @@ func init() {
if thinkingCollapsed { if thinkingCollapsed {
status = "collapsed" status = "collapsed"
} }
if err := notifyUser("thinking", "Thinking blocks "+status); err != nil { showToast("thinking", "Thinking blocks "+status)
logger.Error("failed to send notification", "error", err) return nil
}
// Handle Ctrl+T to toggle tool call/response visibility
if event.Key() == tcell.KeyCtrlT {
toolCollapsed = !toolCollapsed
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
colorText()
status := "expanded"
if toolCollapsed {
status = "collapsed"
} }
showToast("tools", "Tool calls/responses "+status)
return nil return nil
} }
if event.Key() == tcell.KeyRune && event.Rune() == 'i' && event.Modifiers()&tcell.ModAlt != 0 { if event.Key() == tcell.KeyRune && event.Rune() == 'i' && event.Modifiers()&tcell.ModAlt != 0 {
@@ -580,9 +661,7 @@ func init() {
// Check if there are no chats for this agent // Check if there are no chats for this agent
if len(chatList) == 0 { if len(chatList) == 0 {
notification := "no chats found for agent: " + cfg.AssistantRole notification := "no chats found for agent: " + cfg.AssistantRole
if err := notifyUser("info", notification); err != nil { showToast("info", notification)
logger.Error("failed to send notification", "error", err)
}
return nil return nil
} }
chatMap := make(map[string]models.Chat) chatMap := make(map[string]models.Chat)
@@ -600,9 +679,7 @@ func init() {
if event.Key() == tcell.KeyF2 && !botRespMode { if event.Key() == tcell.KeyF2 && !botRespMode {
// regen last msg // regen last msg
if len(chatBody.Messages) == 0 { if len(chatBody.Messages) == 0 {
if err := notifyUser("info", "no messages to regenerate"); err != nil { showToast("info", "no messages to regenerate")
logger.Error("failed to send notification", "error", err)
}
return nil return nil
} }
chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1] chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
@@ -628,9 +705,7 @@ func init() {
return nil return nil
} }
if len(chatBody.Messages) == 0 { if len(chatBody.Messages) == 0 {
if err := notifyUser("info", "no messages to delete"); err != nil { showToast("info", "no messages to delete")
logger.Error("failed to send notification", "error", err)
}
return nil return nil
} }
chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1] chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
@@ -676,6 +751,7 @@ func init() {
if event.Key() == tcell.KeyF6 { if event.Key() == tcell.KeyF6 {
interruptResp = true interruptResp = true
botRespMode = false botRespMode = false
toolRunningMode = false
return nil return nil
} }
if event.Key() == tcell.KeyF7 { if event.Key() == tcell.KeyF7 {
@@ -688,9 +764,7 @@ func init() {
} }
previewLen := min(30, len(msgText)) previewLen := min(30, len(msgText))
notification := fmt.Sprintf("msg '%s' was copied to the clipboard", msgText[:previewLen]) notification := fmt.Sprintf("msg '%s' was copied to the clipboard", msgText[:previewLen])
if err := notifyUser("copied", notification); err != nil { showToast("copied", notification)
logger.Error("failed to send notification", "error", err)
}
return nil return nil
} }
if event.Key() == tcell.KeyF8 { if event.Key() == tcell.KeyF8 {
@@ -704,9 +778,7 @@ func init() {
text := textView.GetText(false) text := textView.GetText(false)
cb := codeBlockRE.FindAllString(text, -1) cb := codeBlockRE.FindAllString(text, -1)
if len(cb) == 0 { if len(cb) == 0 {
if err := notifyUser("notify", "no code blocks in chat"); err != nil { showToast("notify", "no code blocks in chat")
logger.Error("failed to send notification", "error", err)
}
return nil return nil
} }
table := makeCodeBlockTable(cb) table := makeCodeBlockTable(cb)
@@ -721,9 +793,7 @@ func init() {
// read files in chat_exports // read files in chat_exports
filelist, err := os.ReadDir(exportDir) filelist, err := os.ReadDir(exportDir)
if err != nil { if err != nil {
if err := notifyUser("failed to load exports", err.Error()); err != nil { showToast("failed to load exports", err.Error())
logger.Error("failed to send notification", "error", err)
}
return nil return nil
} }
fli := []string{} fli := []string{}
@@ -753,9 +823,7 @@ func init() {
logger.Error("failed to export chat;", "error", err, "chat_name", activeChatName) logger.Error("failed to export chat;", "error", err, "chat_name", activeChatName)
return nil return nil
} }
if err := notifyUser("exported chat", "chat: "+activeChatName+" was exported"); err != nil { showToast("exported chat", "chat: "+activeChatName+" was exported")
logger.Error("failed to send notification", "error", err)
}
return nil return nil
} }
if event.Key() == tcell.KeyCtrlP { if event.Key() == tcell.KeyCtrlP {
@@ -781,14 +849,6 @@ func init() {
showModelSelectionPopup() showModelSelectionPopup()
return nil return nil
} }
if event.Key() == tcell.KeyCtrlT {
// clear context
// remove tools and thinking
removeThinking(chatBody)
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
colorText()
return nil
}
if event.Key() == tcell.KeyCtrlV { if event.Key() == tcell.KeyCtrlV {
if isFullScreenPageActive() { if isFullScreenPageActive() {
return event return event
@@ -802,9 +862,7 @@ func init() {
labels, err := initSysCards() labels, err := initSysCards()
if err != nil { if err != nil {
logger.Error("failed to read sys dir", "error", err) logger.Error("failed to read sys dir", "error", err)
if err := notifyUser("error", "failed to read: "+cfg.SysDir); err != nil { showToast("error", "failed to read: "+cfg.SysDir)
logger.Debug("failed to notify user", "error", err)
}
return nil return nil
} }
at := makeAgentTable(labels) at := makeAgentTable(labels)
@@ -817,6 +875,7 @@ func init() {
if event.Key() == tcell.KeyCtrlK { if event.Key() == tcell.KeyCtrlK {
// add message from tools // add message from tools
cfg.ToolUse = !cfg.ToolUse cfg.ToolUse = !cfg.ToolUse
updateToolCapabilities()
updateStatusLine() updateStatusLine()
return nil return nil
} }
@@ -828,21 +887,27 @@ func init() {
if err != nil { if err != nil {
logger.Error("failed to open attached image", "path", lastImg, "error", err) logger.Error("failed to open attached image", "path", lastImg, "error", err)
// Fall back to showing agent image // Fall back to showing agent image
loadImage() if err := loadImage(); err != nil {
logger.Warn("failed to load agent image", "error", err)
}
} else { } else {
defer file.Close() defer file.Close()
img, _, err := image.Decode(file) img, _, err := image.Decode(file)
if err != nil { if err != nil {
logger.Error("failed to decode attached image", "path", lastImg, "error", err) logger.Error("failed to decode attached image", "path", lastImg, "error", err)
// Fall back to showing agent image // Fall back to showing agent image
loadImage() if err := loadImage(); err != nil {
logger.Warn("failed to load agent image", "error", err)
}
} else { } else {
imgView.SetImage(img) imgView.SetImage(img)
} }
} }
} else { } else {
// No attached image, show agent image as before // No attached image, show agent image as before
loadImage() if err := loadImage(); err != nil {
logger.Warn("failed to load agent image", "error", err)
}
} }
pages.AddPage(imgPage, imgView, true, true) pages.AddPage(imgPage, imgView, true, true)
return nil return nil
@@ -854,9 +919,7 @@ func init() {
if err != nil { if err != nil {
msg := "failed to inference user speech; error:" + err.Error() msg := "failed to inference user speech; error:" + err.Error()
logger.Error(msg) logger.Error(msg)
if err := notifyUser("stt error", msg); err != nil { showToast("stt error", msg)
logger.Error("failed to notify user", "error", err)
}
return nil return nil
} }
if userSpeech != "" { if userSpeech != "" {
@@ -914,6 +977,17 @@ func init() {
showBotRoleSelectionPopup() showBotRoleSelectionPopup()
return nil return nil
} }
// INFO: shutdown
if event.Key() == tcell.KeyCtrlC {
logger.Info("caught Ctrl+C via tcell event")
go func() {
if err := pwShutDown(); err != nil {
logger.Error("shutdown failed", "err", err)
}
app.Stop()
}()
return nil // swallow the event
}
if event.Key() == tcell.KeyCtrlG { if event.Key() == tcell.KeyCtrlG {
// cfg.RAGDir is the directory with files to use with RAG // cfg.RAGDir is the directory with files to use with RAG
// rag load // rag load
@@ -925,26 +999,20 @@ func init() {
// Create the RAG directory if it doesn't exist // Create the RAG directory if it doesn't exist
if mkdirErr := os.MkdirAll(cfg.RAGDir, 0755); mkdirErr != nil { if mkdirErr := os.MkdirAll(cfg.RAGDir, 0755); mkdirErr != nil {
logger.Error("failed to create RAG directory", "dir", cfg.RAGDir, "error", mkdirErr) logger.Error("failed to create RAG directory", "dir", cfg.RAGDir, "error", mkdirErr)
if notifyerr := notifyUser("failed to create RAG directory", mkdirErr.Error()); notifyerr != nil { showToast("failed to create RAG directory", mkdirErr.Error())
logger.Error("failed to send notification", "error", notifyerr)
}
return nil return nil
} }
// Now try to read the directory again after creating it // Now try to read the directory again after creating it
files, err = os.ReadDir(cfg.RAGDir) files, err = os.ReadDir(cfg.RAGDir)
if err != nil { if err != nil {
logger.Error("failed to read dir after creating it", "dir", cfg.RAGDir, "error", err) logger.Error("failed to read dir after creating it", "dir", cfg.RAGDir, "error", err)
if notifyerr := notifyUser("failed to read RAG directory", err.Error()); notifyerr != nil { showToast("failed to read RAG directory", err.Error())
logger.Error("failed to send notification", "error", notifyerr)
}
return nil return nil
} }
} else { } else {
// Other error (permissions, etc.) // Other error (permissions, etc.)
logger.Error("failed to read dir", "dir", cfg.RAGDir, "error", err) logger.Error("failed to read dir", "dir", cfg.RAGDir, "error", err)
if notifyerr := notifyUser("failed to open RAG files dir", err.Error()); notifyerr != nil { showToast("failed to open RAG files dir", err.Error())
logger.Error("failed to send notification", "error", notifyerr)
}
return nil return nil
} }
} }
@@ -974,9 +1042,7 @@ func init() {
if event.Key() == tcell.KeyRune && event.Modifiers() == tcell.ModAlt && event.Rune() == '9' { if event.Key() == tcell.KeyRune && event.Modifiers() == tcell.ModAlt && event.Rune() == '9' {
// Warm up (load) the currently selected model // Warm up (load) the currently selected model
go warmUpModel() go warmUpModel()
if err := notifyUser("model warmup", "loading model: "+chatBody.Model); err != nil { showToast("model warmup", "loading model: "+chatBody.Model)
logger.Debug("failed to notify user", "error", err)
}
return nil return nil
} }
// cannot send msg in editMode or botRespMode // cannot send msg in editMode or botRespMode
@@ -990,41 +1056,54 @@ func init() {
return nil return nil
} }
msgText := textArea.GetText() msgText := textArea.GetText()
nl := "\n\n" // keep empty lines between messages
prevText := textView.GetText(true)
persona := cfg.UserRole
// strings.LastIndex()
// newline is not needed is prev msg ends with one
if strings.HasSuffix(prevText, nl) {
nl = ""
} else if strings.HasSuffix(prevText, "\n") {
nl = "\n" // only one newline, add another
}
if msgText != "" { if msgText != "" {
nl := "\n\n" // keep empty lines between messages // as what char user sends msg?
prevText := textView.GetText(true) if cfg.WriteNextMsgAs != "" {
persona := cfg.UserRole persona = cfg.WriteNextMsgAs
// strings.LastIndex()
// newline is not needed is prev msg ends with one
if strings.HasSuffix(prevText, nl) {
nl = ""
} else if strings.HasSuffix(prevText, "\n") {
nl = "\n" // only one newline, add another
} }
if msgText != "" { // check if plain text
// as what char user sends msg? if !injectRole {
if cfg.WriteNextMsgAs != "" { matches := roleRE.FindStringSubmatch(msgText)
persona = cfg.WriteNextMsgAs if len(matches) > 1 {
persona = matches[1]
msgText = strings.TrimLeft(msgText[len(matches[0]):], " ")
} }
// check if plain text
if !injectRole {
matches := roleRE.FindStringSubmatch(msgText)
if len(matches) > 1 {
persona = matches[1]
msgText = strings.TrimLeft(msgText[len(matches[0]):], " ")
}
}
// add user icon before user msg
fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
nl, len(chatBody.Messages), persona, msgText)
textArea.SetText("", true)
if scrollToEndEnabled {
textView.ScrollToEnd()
}
colorText()
} }
// go chatRound(msgText, persona, textView, false, false) // add user icon before user msg
chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: msgText} fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
nl, len(chatBody.Messages), persona, msgText)
textArea.SetText("", true)
if scrollToEndEnabled {
textView.ScrollToEnd()
}
colorText()
} else {
pages.AddPage(confirmPageName, confirmModal, true, true)
return nil
}
// go chatRound(msgText, persona, textView, false, false)
chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: msgText}
return nil
}
if event.Key() == tcell.KeyTab {
currentF := app.GetFocus()
if currentF == textArea {
currentText := textArea.GetText()
atIndex := strings.LastIndex(currentText, "@")
if atIndex >= 0 {
filter := currentText[atIndex+1:]
showTextAreaFileCompletionPopup(filter)
}
} }
return nil return nil
} }