32 Commits

Author SHA1 Message Date
Grail Finder
c2c107c786 Dep: make-fetch onnx embed gemma 2026-03-05 16:05:03 +03:00
Grail Finder
c2757653a3 Fix: buildable 2026-03-05 14:49:59 +03:00
Grail Finder
4bd6883966 WIP 2026-03-05 14:38:26 +03:00
Grail Finder
7c56e27dbe Dep: trying sugarme tokenizer 2026-03-05 14:27:19 +03:00
Grail Finder
fbc955ca37 Enha: local onnx 2026-03-05 14:13:58 +03:00
Grail Finder
c65c11bcfb Fix: shellmode tab completion 2026-03-05 11:36:35 +03:00
Grail Finder
04f1fd464b Chore: remove cluedo sysprompt 2026-03-05 11:17:01 +03:00
Grail Finder
6e9c453ee0 Enha: explicit app.Draw per textView update for smooth streaming 2026-03-05 10:35:17 +03:00
Grail Finder
645b7351a8 Fix: add different kind of notifiction for fullscreen mode 2026-03-05 09:09:13 +03:00
Grail Finder
57088565bd Fix (notification): being closed by prev notification early 2026-03-05 08:51:04 +03:00
Grail Finder
4b6769e531 Fix (notification): non-blocking way to notify 2026-03-05 08:43:50 +03:00
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
26 changed files with 1812 additions and 259 deletions

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ gflt
chat_exports/*.json chat_exports/*.json
ragimport ragimport
.env .env
onnx/

View File

@@ -30,6 +30,9 @@ lint: ## Run linters. Use make install-linters first.
lintall: lint lintall: lint
noblanks ./... noblanks ./...
fetch-onnx:
mkdir -p onnx/embedgemma && curl -o onnx/embedgemma/config.json -L https://huggingface.co/onnx-community/embeddinggemma-300m-ONNX/resolve/main/config.json && curl -o onnx/embedgemma/tokenizer.json -L https://huggingface.co/onnx-community/embeddinggemma-300m-ONNX/resolve/main/tokenizer.json && curl -o onnx/embedgemma/model_q4.onnx -L https://huggingface.co/onnx-community/embeddinggemma-300m-ONNX/resolve/main/onnx/model_q4.onnx && curl -o onnx/embedgemma/model_q4.onnx_data -L https://huggingface.co/onnx-community/embeddinggemma-300m-ONNX/resolve/main/onnx/model_q4.onnx_data?download=true
# 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

91
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,7 +64,9 @@ 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>`) var thinkBlockRE = regexp.MustCompile(`(?s)<think>.*?</think>`)
@@ -265,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() {
@@ -355,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
} }
@@ -416,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
} }
@@ -478,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
} }
@@ -491,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 {
@@ -556,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
} }
@@ -570,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
} }
@@ -583,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
@@ -593,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
@@ -632,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
@@ -668,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
} }
@@ -745,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
@@ -799,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()
@@ -964,6 +963,9 @@ out:
} }
// Strip think blocks before parsing for tool calls // Strip think blocks before parsing for tool calls
respTextNoThink := thinkBlockRE.ReplaceAllString(respText.String(), "") respTextNoThink := thinkBlockRE.ReplaceAllString(respText.String(), "")
if interruptResp {
return nil
}
if findCall(respTextNoThink, toolResp.String()) { if findCall(respTextNoThink, toolResp.String()) {
return nil return nil
} }
@@ -1374,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)
@@ -1408,7 +1410,7 @@ func updateModelLists() {
chatBody.Model = m chatBody.Model = m
cachedModelColor = "green" cachedModelColor = "green"
updateStatusLine() updateStatusLine()
UpdateToolCapabilities() updateToolCapabilities()
app.Draw() app.Draw()
return return
} }
@@ -1436,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
@@ -1463,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() {
@@ -1522,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

@@ -56,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

@@ -70,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

@@ -162,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.

11
go.mod
View File

@@ -14,25 +14,36 @@ 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/sugarme/tokenizer v0.3.0
github.com/yalue/onnxruntime_go v1.27.0
github.com/yuin/goldmark v1.4.13 github.com/yuin/goldmark v1.4.13
) )
require ( require (
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
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/emirpasic/gods v1.18.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
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/schollz/progressbar/v2 v2.15.0 // indirect
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect

29
go.sum
View File

@@ -10,22 +10,32 @@ 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=
github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI= github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.2 h1:5j4srfF8ow3HICOv/61/sOhQtA25qxEB2XR3Q/Bhx2g= github.com/gdamore/tcell/v2 v2.13.2 h1:5j4srfF8ow3HICOv/61/sOhQtA25qxEB2XR3Q/Bhx2g=
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=
@@ -53,12 +63,18 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/neurosnap/sentences v1.1.2 h1:iphYOzx/XckXeBiLIUBkPu2EKMJ+6jDbz/sLJZ7ZoUw= github.com/neurosnap/sentences v1.1.2 h1:iphYOzx/XckXeBiLIUBkPu2EKMJ+6jDbz/sLJZ7ZoUw=
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/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
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,8 +83,19 @@ 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/schollz/progressbar/v2 v2.15.0 h1:dVzHQ8fHRmtPjD3K10jT3Qgn/+H+92jhPrhmxIJfDz8=
github.com/schollz/progressbar/v2 v2.15.0/go.mod h1:UdPq3prGkfQ7MOzZKlDRpYKcFqEMczbD7YmbPgpzKMI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c h1:pwb4kNSHb4K89ymCaN+5lPH/MwnfSVg4rzGDh4d+iy4=
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c/go.mod h1:2gwkXLWbDGUQWeL3RtpCmcY4mzCtU13kb9UsAg9xMaw=
github.com/sugarme/tokenizer v0.3.0 h1:FE8DYbNSz/kSbgEo9l/RjgYHkIJYEdskumitFQBE9FE=
github.com/sugarme/tokenizer v0.3.0/go.mod h1:VJ+DLK5ZEZwzvODOWwY0cw+B1dabTd3nCB5HuFCItCc=
github.com/yalue/onnxruntime_go v1.27.0 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U=
github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -152,6 +179,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,6 +11,7 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv"
"strings" "strings"
"time" "time"
"unicode" "unicode"
@@ -197,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
@@ -286,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 {
@@ -376,9 +382,90 @@ 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
} }
func getContextTokens() int {
if chatBody == nil || chatBody.Messages == nil {
return 0
}
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
func listChatRoles() []string { func listChatRoles() []string {
currentChat, ok := chatMap[activeChatName] currentChat, ok := chatMap[activeChatName]
@@ -386,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{}
@@ -408,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
} }
} }
@@ -441,7 +521,7 @@ func updateFlexLayout() {
if shellMode { if shellMode {
flex.AddItem(shellInput, 0, 10, false) flex.AddItem(shellInput, 0, 10, false)
} else { } else {
flex.AddItem(textArea, 0, 10, false) flex.AddItem(bottomFlex, 0, 10, true)
} }
if positionVisible { if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false) flex.AddItem(statusLineWidget, 0, 2, false)
@@ -611,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
@@ -646,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
@@ -738,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)
@@ -750,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 {

22
llm.go
View File

@@ -224,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{
@@ -349,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,
} }
@@ -414,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)
@@ -496,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,
} }
@@ -558,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,

View File

@@ -17,8 +17,9 @@ var (
shellHistoryPos int = -1 shellHistoryPos int = -1
thinkingCollapsed = false thinkingCollapsed = false
toolCollapsed = true 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)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)" 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,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"`

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

@@ -40,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
@@ -119,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
@@ -143,7 +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() // 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 {
@@ -163,7 +159,7 @@ func showAPILinkSelectionPopup() {
if len(newModelList) > 0 && !slices.Contains(newModelList, chatBody.Model) { if len(newModelList) > 0 && !slices.Contains(newModelList, chatBody.Model) {
chatBody.Model = strings.TrimPrefix(newModelList[0], models.LoadedMark) chatBody.Model = strings.TrimPrefix(newModelList[0], models.LoadedMark)
cfg.CurrentModel = chatBody.Model cfg.CurrentModel = chatBody.Model
UpdateToolCapabilities() updateToolCapabilities()
} }
pages.RemovePage("apiLinkSelectionPopup") pages.RemovePage("apiLinkSelectionPopup")
app.SetFocus(textArea) app.SetFocus(textArea)
@@ -206,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
@@ -285,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
@@ -406,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
@@ -452,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

@@ -9,6 +9,10 @@ import (
"gf-lt/models" "gf-lt/models"
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/sugarme/tokenizer"
"github.com/sugarme/tokenizer/pretrained"
"github.com/yalue/onnxruntime_go"
) )
// Embedder defines the interface for embedding text // Embedder defines the interface for embedding text
@@ -134,11 +138,150 @@ func (a *APIEmbedder) EmbedSlice(lines []string) ([][]float32, error) {
return embeddings, nil return embeddings, nil
} }
// TODO: ONNXEmbedder implementation would go here
// This would require:
// 1. Loading ONNX models locally // 1. Loading ONNX models locally
// 2. Using a Go ONNX runtime (like gorgonia/onnx or similar) // 2. Using a Go ONNX runtime (like gorgonia/onnx or similar)
// 3. Converting text to embeddings without external API calls // 3. Converting text to embeddings without external API calls
// type ONNXEmbedder struct {
// For now, we'll focus on the API implementation which is already working in the current system, session *onnxruntime_go.DynamicAdvancedSession
// and can be extended later when we have ONNX runtime integration tokenizer *tokenizer.Tokenizer
dims int // embedding dimension (e.g., 768)
logger *slog.Logger
}
func NewONNXEmbedder(modelPath, tokenizerPath string, dims int, logger *slog.Logger) (*ONNXEmbedder, error) {
// Load tokenizer using sugarme/tokenizer
tok, err := pretrained.FromFile(tokenizerPath)
if err != nil {
return nil, fmt.Errorf("failed to load tokenizer: %w", err)
}
// Create ONNX session
session, err := onnxruntime_go.NewDynamicAdvancedSession(
modelPath, // onnx/embedgemma/model_q4.onnx
[]string{"input_ids", "attention_mask"},
[]string{"sentence_embedding"},
nil, // optional options
)
if err != nil {
return nil, fmt.Errorf("failed to create ONNX session: %w", err)
}
return &ONNXEmbedder{
session: session,
tokenizer: tok,
dims: dims,
logger: logger,
}, nil
}
func (e *ONNXEmbedder) Embed(text string) ([]float32, error) {
// 1. Tokenize
encoding, err := e.tokenizer.EncodeSingle(text)
if err != nil {
return nil, fmt.Errorf("tokenization failed: %w", err)
}
// 2. Convert to int64 and create attention mask
ids := encoding.Ids
inputIDs := make([]int64, len(ids))
attentionMask := make([]int64, len(ids))
for i, id := range ids {
inputIDs[i] = int64(id)
attentionMask[i] = 1
}
// 3. Create input tensors (shape: [1, seq_len])
seqLen := int64(len(inputIDs))
inputIDsTensor, err := onnxruntime_go.NewTensor[int64](
onnxruntime_go.NewShape(1, seqLen),
inputIDs,
)
if err != nil {
return nil, fmt.Errorf("failed to create input_ids tensor: %w", err)
}
defer inputIDsTensor.Destroy()
maskTensor, err := onnxruntime_go.NewTensor[int64](
onnxruntime_go.NewShape(1, seqLen),
attentionMask,
)
if err != nil {
return nil, fmt.Errorf("failed to create attention_mask tensor: %w", err)
}
defer maskTensor.Destroy()
// 4. Create output tensor
outputTensor, err := onnxruntime_go.NewEmptyTensor[float32](
onnxruntime_go.NewShape(1, int64(e.dims)),
)
if err != nil {
return nil, fmt.Errorf("failed to create output tensor: %w", err)
}
defer outputTensor.Destroy()
// 5. Run inference
err = e.session.Run(
[]onnxruntime_go.Value{inputIDsTensor, maskTensor},
[]onnxruntime_go.Value{outputTensor},
)
if err != nil {
return nil, fmt.Errorf("inference failed: %w", err)
}
// 6. Copy output data
outputData := outputTensor.GetData()
embedding := make([]float32, len(outputData))
copy(embedding, outputData)
return embedding, nil
}
func (e *ONNXEmbedder) EmbedSlice(texts []string) ([][]float32, error) {
encodings := make([]*tokenizer.Encoding, len(texts))
maxLen := 0
for i, txt := range texts {
enc, err := e.tokenizer.EncodeSingle(txt)
if err != nil {
return nil, err
}
encodings[i] = enc
if l := len(enc.Ids); l > maxLen {
maxLen = l
}
}
batchSize := len(texts)
inputIDs := make([]int64, batchSize*maxLen)
attentionMask := make([]int64, batchSize*maxLen)
for i, enc := range encodings {
ids := enc.Ids
offset := i * maxLen
for j, id := range ids {
inputIDs[offset+j] = int64(id)
attentionMask[offset+j] = 1
}
// Remaining positions are already zero (padding)
}
// Create tensors with shape [batchSize, maxLen]
inputTensor, _ := onnxruntime_go.NewTensor[int64](
onnxruntime_go.NewShape(int64(batchSize), int64(maxLen)),
inputIDs,
)
defer inputTensor.Destroy()
maskTensor, _ := onnxruntime_go.NewTensor[int64](
onnxruntime_go.NewShape(int64(batchSize), int64(maxLen)),
attentionMask,
)
defer maskTensor.Destroy()
outputTensor, _ := onnxruntime_go.NewEmptyTensor[float32](
onnxruntime_go.NewShape(int64(batchSize), int64(e.dims)),
)
defer outputTensor.Destroy()
err := e.session.Run(
[]onnxruntime_go.Value{inputTensor, maskTensor},
[]onnxruntime_go.Value{outputTensor},
)
if err != nil {
return nil, err
}
// Extract embeddings per batch item
data := outputTensor.GetData()
embeddings := make([][]float32, batchSize)
for i := 0; i < batchSize; i++ {
start := i * e.dims
emb := make([]float32, e.dims)
copy(emb, data[start:start+e.dims])
embeddings[i] = emb
}
return embeddings, nil
}

View File

@@ -246,7 +246,7 @@ func (r *RAG) extractImportantPhrases(query string) string {
break break
} }
} }
if isImportant || len(word) > 3 { if isImportant || len(word) >= 3 {
important = append(important, word) important = append(important, word)
} }
} }

104
rag_issues.md Normal file
View File

@@ -0,0 +1,104 @@
# RAG Implementation Issues and Proposed Solutions
## Overview
The current RAG system fails to retrieve relevant information for specific queries, as demonstrated by the inability to find the "two she bears" reference in the KJV Bible (2 Kings 2:23-24). While the system retrieves documents containing the word "bear", it misses the actual verse, indicating fundamental flaws in chunking, query processing, retrieval, and answer synthesis. Below we dissect each problem and propose concrete solutions.
---
### Problem 1: Chunking Destroys Semantic Coherence
- **Problem description**
The current chunking splits text into sentences and groups them by a simple word count threshold (`RAGWordLimit`). This ignores document structure (chapters, headings) and can cut through narrative units, scattering related content across multiple chunks. For the Bible query, the story of Elisha and the bears likely spans multiple verses; splitting it prevents any single chunk from containing the full context, diluting the embedding signal and making retrieval difficult.
- **Proposed solution**
- **Structure-aware chunking**: Use the EPUBs internal structure (chapters, sections) to create chunks that align with logical content units (e.g., by chapter or story).
- **Overlap between chunks**: Add a configurable overlap (e.g., 1020% of chunk size) to preserve continuity, ensuring key phrases like "two she bears" are not split across boundaries.
- **Rich metadata**: Store book name, chapter, and verse numbers with each chunk to enable filtering and source attribution.
- **Fallback to recursive splitting**: For documents without clear structure, use a recursive character text splitter with overlap (similar to LangChains `RecursiveCharacterTextSplitter`) to maintain semantic boundaries (paragraphs, sentences).
---
### Problem 2: Query Refinement Strips Critical Context
- **Problem description**
`RefineQuery` removes stop words and applies keyword-based filtering that discards semantically important modifiers. For "two she bears", the word "she" (a gender modifier) may be treated as a stop word, leaving "two bears". This loses the specificity of the query and causes the embedding to drift toward generic "bear" contexts. The rule-based approach cannot understand that "she bears" is a key phrase in the biblical story.
- **Proposed solution**
- **Entity-aware query preservation**: Use a lightweight NLP model (e.g., spaCy or a BERT-based NER tagger) to identify and retain key entities (quantities, animals, names) while only removing truly irrelevant stop words.
- **Intelligent query rewriting**: Employ a small LLM (or a set of transformation rules) to generate query variations that reflect likely biblical phrasing, e.g., "two bears came out of the wood" or "Elisha and the bears".
- **Contextual stop word removal**: Instead of a static list, use a POS tagger to keep adjectives, nouns, and verbs while removing only function words that don't carry meaning.
- **Disable refinement for short queries**: If the query is already concise (like "two she bears"), skip aggressive filtering.
---
### Problem 3: Embedding Similarity Fails for Rare or Specific Phrases
- **Problem description**
Dense embeddings excel at capturing semantic similarity but can fail when the query contains rare phrases or when the relevant passage is embedded in a noisy chunk. The verse "there came forth two she bears out of the wood" shares only the word "bears" with the query, and its embedding may be pulled toward the average of surrounding verses. Consequently, the similarity score may be lower than that of other chunks containing the word "bear" in generic contexts.
- **Proposed solution**
- **Hybrid retrieval**: Combine dense embeddings with BM25 (keyword) search. BM25 excels at exact phrase matching and would likely retrieve the verse based on "two bears" even if the embedding is weak.
- Use a library like [blevesearch](https://github.com/blevesearch/bleve) to index text alongside vectors.
- Fuse results using Reciprocal Rank Fusion (RRF) or a weighted combination.
- **Query expansion**: Add relevant terms to the query (e.g., "Elisha", "2 Kings") to improve embedding alignment.
- **Fine-tuned embeddings**: Consider using an embedding model fine-tuned on domain-specific data (e.g., biblical texts) if this is a recurring use case.
---
### Problem 4: Reranking Heuristics Are Insufficient
- **Problem description**
`RerankResults` boosts results based on simple keyword matching and file name heuristics. This coarse approach cannot reliably promote the correct verse over false positives. The adjustment `distance - score/100` is arbitrary and may not reflect true relevance.
- **Proposed solution**
- **Cross-encoder reranking**: After retrieving top candidates (e.g., top 20) with hybrid search, rerank them using a cross-encoder model that directly computes the relevance score between the query and each chunk.
- Models like `cross-encoder/ms-marco-MiniLM-L-6-v2` are lightweight and can be run locally or via a microservice.
- **Score normalization**: Use the cross-encoder scores to reorder results, discarding low-scoring ones.
- **Contextual boosting**: If metadata (e.g., chapter/verse) is available, boost results that match the querys expected location (if inferable).
---
### Problem 5: Answer Synthesis Is Not Generative
- **Problem description**
`SynthesizeAnswer` embeds a prompt and attempts to retrieve a pre-stored answer, falling back to concatenating truncated chunks. This is fundamentally flawed: RAG requires an LLM to generate a coherent answer from retrieved context. In the Bible example, even if the correct verse were retrieved, the system would only output a snippet, not an answer explaining the reference.
- **Proposed solution**
- **Integrate an LLM for generation**: Use a local model (via Ollama, Llama.cpp) or a cloud API (OpenAI, etc.) to synthesize answers.
- Construct a prompt that includes the retrieved chunks (with metadata) and the user query.
- Instruct the model to answer based solely on the provided context and cite sources (e.g., "According to 2 Kings 2:24...").
- **Implement a fallback**: If no relevant chunks are retrieved, return a message like "I couldn't find that information in your documents."
- **Streaming support**: For better UX, stream the answer token-by-token.
---
### Problem 6: Concurrency and Error Handling
- **Problem description**
The code uses a mutex only in `LoadRAG`, leaving other methods vulnerable to race conditions. The global status channel `LongJobStatusCh` may drop messages due to `select/default`, and errors are sometimes logged but not propagated. Ingestion is synchronous and slow.
- **Proposed solution**
- **Add context support**: Pass `context.Context` to all methods to allow cancellation and timeouts.
- **Worker pools for embedding**: Parallelize batch embedding with a controlled number of workers to respect API rate limits and speed up ingestion.
- **Retry logic**: Implement exponential backoff for transient API errors.
- **Replace global channel**: Use a callback or an injectable status reporter to avoid dropping messages.
- **Fine-grained locking**: Protect shared state (e.g., `storage`) with appropriate synchronization.
---
### Problem 7: Lack of Monitoring and Evaluation
- **Problem description**
There are no metrics to track retrieval quality, latency, or user satisfaction. The failure case was discovered manually; without systematic evaluation, regressions will go unnoticed.
- **Proposed solution**
- **Log key metrics**: Record query, retrieved chunk IDs, scores, and latency for each search.
- **User feedback**: Add a mechanism for users to rate answers (thumbs up/down) and use this data to improve retrieval.
- **Offline evaluation**: Create a test set of queries and expected relevant chunks (e.g., the Bible example) to measure recall@k, MRR, etc., and run it after each change.
---
## Summary
Fixing the RAG pipeline requires a multi-pronged approach:
1. **Structure-aware chunking** with metadata.
2. **Hybrid retrieval** (dense + sparse).
3. **Query understanding** via entity preservation and intelligent rewriting.
4. **Cross-encoder reranking** for precision.
5. **LLM-based answer generation**.
6. **Robust concurrency and error handling**.
7. **Monitoring and evaluation** to track improvements.
Implementing these changes will transform the system from a brittle keyword matcher into a reliable knowledge assistant capable of handling nuanced queries like the "two she bears" reference.

View File

@@ -168,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,7 +0,0 @@
{
"sys_prompt": "A game of cluedo. Players are {{user}}, {{char}}, {{char2}};\n\nrooms: hall, lounge, dinning room kitchen, ballroom, conservatory, billiard room, library, study;\nweapons: candlestick, dagger, lead pipe, revolver, rope, spanner;\npeople: miss Scarlett, colonel Mustard, mrs. White, reverend Green, mrs. Peacock, professor Plum;\n\nA murder happened in a mansion with 9 rooms. Victim is dr. Black.\nPlayers goal is to find out who commited a murder, in what room and with what weapon.\nWeapons, people and rooms not involved in murder are distributed between players (as cards) by tool agent.\nThe objective of the game is to deduce the details of the murder. There are six characters, six murder weapons, and nine rooms, leaving the players with 324 possibilities. As soon as a player enters a room, they may make a suggestion as to the details, naming a suspect, the room they are in, and the weapon. For example: \"I suspect Professor Plum, in the Dining Room, with the candlestick\".\nOnce a player makes a suggestion, the others are called upon to disprove it.\nBefore the player's move, tool agent will remind that players their cards. There are two types of moves: making a suggestion (suggestion_move) and disproving other player suggestion (evidence_move);\nIn this version player wins when the correct details are named in the suggestion_move.\n\n<example_game>\n{{user}}:\nlet's start a game of cluedo!\ntool: cards of {{char}} are 'LEAD PIPE', 'BALLROOM', 'CONSERVATORY', 'STUDY', 'Mrs. White'; suggestion_move;\n{{char}}:\n(putting miss Scarlet into the Hall with the Revolver) \"I suspect miss Scarlett, in the Hall, with the revolver.\"\ntool: cards of {{char2}} are 'SPANNER', 'DAGGER', 'Professor Plum', 'LIBRARY', 'Mrs. Peacock'; evidence_move;\n{{char2}}:\n\"No objections.\" (no cards matching the suspicion of {{char}})\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; evidence_move;\n{{user}}:\n\"I object. Miss Scarlett is innocent.\" (shows card with 'Miss Scarlett')\ntool: cards of {{char2}} are 'SPANNER', 'DAGGER', 'Professor Plum', 'LIBRARY', 'Mrs. Peacock'; suggestion_move;\n{{char2}}:\n*So it was not Miss Scarlett, good to know.*\n(moves Mrs. White to the Billiard Room) \"It might have been Mrs. White, in the Billiard Room, with the Revolver.\"\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; evidence_move;\n{{user}}:\n(no matching cards for the assumption of {{char2}}) \"Sounds possible to me.\"\ntool: cards of {{char}} are 'LEAD PIPE', 'BALLROOM', 'CONSERVATORY', 'STUDY', 'Mrs. White'; evidence_move;\n{{char}}:\n(shows Mrs. White card) \"No. Was not Mrs. White\"\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; suggestion_move;\n{{user}}:\n*So not Mrs. White...* (moves Reverend Green into the Billiard Room) \"I suspect Reverend Green, in the Billiard Room, with the Revolver.\"\ntool: Correct. It was Reverend Green in the Billiard Room, with the revolver. {{user}} wins.\n</example_game>",
"role": "CluedoPlayer",
"role2": "CluedoEnjoyer",
"filepath": "sysprompts/cluedo.json",
"first_msg": "Hey guys! Want to play cluedo?"
}

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:

403
tools.go
View File

@@ -77,17 +77,17 @@ Your current tools:
{ {
"name":"file_create", "name":"file_create",
"args": ["path", "content"], "args": ["path", "content"],
"when_to_use": "when asked to create a new file with optional content" "when_to_use": "when there is a need to create a new file with optional content"
}, },
{ {
"name":"file_read", "name":"file_read",
"args": ["path"], "args": ["path"],
"when_to_use": "when asked to read the content of a file" "when_to_use": "when you need to read the content of a file"
}, },
{ {
"name":"file_read_image", "name":"file_read_image",
"args": ["path"], "args": ["path"],
"when_to_use": "when asked to read or view an image file" "when_to_use": "when you need to read or view an image file"
}, },
{ {
"name":"file_write", "name":"file_write",
@@ -97,7 +97,7 @@ Your current tools:
{ {
"name":"file_write_append", "name":"file_write_append",
"args": ["path", "content"], "args": ["path", "content"],
"when_to_use": "when asked to append content to a file; use sed to edit content" "when_to_use": "when you need append content to a file; use sed to edit content"
}, },
{ {
"name":"file_edit", "name":"file_edit",
@@ -112,22 +112,22 @@ Your current tools:
{ {
"name":"file_move", "name":"file_move",
"args": ["src", "dst"], "args": ["src", "dst"],
"when_to_use": "when asked to move a file from source to destination" "when_to_use": "when you need to move a file from source to destination"
}, },
{ {
"name":"file_copy", "name":"file_copy",
"args": ["src", "dst"], "args": ["src", "dst"],
"when_to_use": "when asked to copy a file from source to destination" "when_to_use": "copy a file from source to destination"
}, },
{ {
"name":"file_list", "name":"file_list",
"args": ["path"], "args": ["path"],
"when_to_use": "when asked to list files in a directory; path is optional (default: current directory)" "when_to_use": "list files in a directory; path is optional (default: current directory)"
}, },
{ {
"name":"execute_command", "name":"execute_command",
"args": ["command", "args"], "args": ["command", "args"],
"when_to_use": "when asked to execute a system command; args is optional; allowed commands: 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, go" "when_to_use": "execute a system command; args is optional; allowed commands: 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, go"
} }
] ]
</tools> </tools>
@@ -162,13 +162,15 @@ After that you are free to respond to the user.
readURLSysPrompt = `Extract and summarize the content from the webpage. Provide key information, main points, and any relevant details.` readURLSysPrompt = `Extract and summarize the content from the webpage. Provide key information, main points, and any relevant details.`
summarySysPrompt = `Please provide a concise summary of the following conversation. Focus on key points, decisions, and actions. Provide only the summary, no additional commentary.` summarySysPrompt = `Please provide a concise summary of the following conversation. Focus on key points, decisions, and actions. Provide only the summary, no additional commentary.`
basicCard = &models.CharCard{ basicCard = &models.CharCard{
ID: models.ComputeCardID("assistant", "basic_sys"),
SysPrompt: basicSysMsg, SysPrompt: basicSysMsg,
FirstMsg: defaultFirstMsg, FirstMsg: defaultFirstMsg,
Role: "", Role: "assistant",
FilePath: "", FilePath: "basic_sys",
} }
sysMap = map[string]*models.CharCard{"basic_sys": basicCard} sysMap = map[string]*models.CharCard{}
sysLabels = []string{"basic_sys"} roleToID = map[string]string{}
sysLabels = []string{"assistant"}
webAgentClient *agent.AgentClient webAgentClient *agent.AgentClient
webAgentClientOnce sync.Once webAgentClientOnce sync.Once
@@ -206,11 +208,17 @@ var (
) )
func init() { func init() {
sysMap[basicCard.ID] = basicCard
roleToID["assistant"] = basicCard.ID
sa, err := searcher.NewWebSurfer(searcher.SearcherTypeScraper, "") sa, err := searcher.NewWebSurfer(searcher.SearcherTypeScraper, "")
if err != nil { if err != nil {
panic("failed to init seachagent; error: " + err.Error()) if logger != nil {
logger.Warn("search agent unavailable; web_search tool disabled", "error", err)
}
WebSearcher = nil
} else {
WebSearcher = sa
} }
WebSearcher = sa
if err := rag.Init(cfg, logger, store); err != nil { if err := rag.Init(cfg, logger, store); err != nil {
logger.Warn("failed to init rag; rag_search tool will not be available", "error", err) logger.Warn("failed to init rag; rag_search tool will not be available", "error", err)
} }
@@ -218,6 +226,14 @@ func init() {
registerWindowTools() registerWindowTools()
} }
func GetCardByRole(role string) *models.CharCard {
cardID, ok := roleToID[role]
if !ok {
return nil
}
return sysMap[cardID]
}
func checkWindowTools() { func checkWindowTools() {
xdotoolPath, _ = exec.LookPath("xdotool") xdotoolPath, _ = exec.LookPath("xdotool")
maimPath, _ = exec.LookPath("maim") maimPath, _ = exec.LookPath("maim")
@@ -234,7 +250,7 @@ func checkWindowTools() {
} }
} }
func UpdateToolCapabilities() { func updateToolCapabilities() {
if !cfg.ToolUse { if !cfg.ToolUse {
return return
} }
@@ -242,6 +258,7 @@ func UpdateToolCapabilities() {
if cfg == nil || cfg.CurrentAPI == "" { if cfg == nil || cfg.CurrentAPI == "" {
logger.Warn("cannot determine model capabilities: cfg or CurrentAPI is nil") logger.Warn("cannot determine model capabilities: cfg or CurrentAPI is nil")
registerWindowTools() registerWindowTools()
registerPlaywrightTools()
return return
} }
prevHasVision := modelHasVision prevHasVision := modelHasVision
@@ -251,20 +268,27 @@ func UpdateToolCapabilities() {
} else { } else {
logger.Info("model does not have vision support", "model", cfg.CurrentModel, "api", cfg.CurrentAPI) logger.Info("model does not have vision support", "model", cfg.CurrentModel, "api", cfg.CurrentAPI)
if windowToolsAvailable && !prevHasVision && !modelHasVision { if windowToolsAvailable && !prevHasVision && !modelHasVision {
_ = notifyUser("window tools", "Window capture-and-view unavailable: model lacks vision support") showToast("window tools", "Window capture-and-view unavailable: model lacks vision support")
} }
} }
registerWindowTools() registerWindowTools()
registerPlaywrightTools()
} }
// getWebAgentClient returns a singleton AgentClient for web agents. // getWebAgentClient returns a singleton AgentClient for web agents.
func getWebAgentClient() *agent.AgentClient { func getWebAgentClient() *agent.AgentClient {
webAgentClientOnce.Do(func() { webAgentClientOnce.Do(func() {
if cfg == nil { if cfg == nil {
panic("cfg not initialized") if logger != nil {
logger.Warn("web agent client unavailable: config not initialized")
}
return
} }
if logger == nil { if logger == nil {
panic("logger not initialized") if logger != nil {
logger.Warn("web agent client unavailable: logger not initialized")
}
return
} }
getToken := func() string { getToken := func() string {
if chunkParser == nil { if chunkParser == nil {
@@ -1364,7 +1388,62 @@ var fnMap = map[string]fnSig{
"summarize_chat": summarizeChat, "summarize_chat": summarizeChat,
} }
func removeWindowToolsFromBaseTools() {
windowToolNames := map[string]bool{
"list_windows": true,
"capture_window": true,
"capture_window_and_view": true,
}
var filtered []models.Tool
for _, tool := range baseTools {
if !windowToolNames[tool.Function.Name] {
filtered = append(filtered, tool)
}
}
baseTools = filtered
delete(fnMap, "list_windows")
delete(fnMap, "capture_window")
delete(fnMap, "capture_window_and_view")
}
func removePlaywrightToolsFromBaseTools() {
playwrightToolNames := map[string]bool{
"pw_start": true,
"pw_stop": true,
"pw_is_running": true,
"pw_navigate": true,
"pw_click": true,
"pw_click_at": true,
"pw_fill": true,
"pw_extract_text": true,
"pw_screenshot": true,
"pw_screenshot_and_view": true,
"pw_wait_for_selector": true,
"pw_drag": true,
}
var filtered []models.Tool
for _, tool := range baseTools {
if !playwrightToolNames[tool.Function.Name] {
filtered = append(filtered, tool)
}
}
baseTools = filtered
delete(fnMap, "pw_start")
delete(fnMap, "pw_stop")
delete(fnMap, "pw_is_running")
delete(fnMap, "pw_navigate")
delete(fnMap, "pw_click")
delete(fnMap, "pw_click_at")
delete(fnMap, "pw_fill")
delete(fnMap, "pw_extract_text")
delete(fnMap, "pw_screenshot")
delete(fnMap, "pw_screenshot_and_view")
delete(fnMap, "pw_wait_for_selector")
delete(fnMap, "pw_drag")
}
func registerWindowTools() { func registerWindowTools() {
removeWindowToolsFromBaseTools()
if windowToolsAvailable { if windowToolsAvailable {
fnMap["list_windows"] = listWindows fnMap["list_windows"] = listWindows
fnMap["capture_window"] = captureWindow fnMap["capture_window"] = captureWindow
@@ -1424,6 +1503,294 @@ func registerWindowTools() {
} }
} }
func registerPlaywrightTools() {
removePlaywrightToolsFromBaseTools()
if cfg != nil && cfg.PlaywrightEnabled {
fnMap["pw_start"] = pwStart
fnMap["pw_stop"] = pwStop
fnMap["pw_is_running"] = pwIsRunning
fnMap["pw_navigate"] = pwNavigate
fnMap["pw_click"] = pwClick
fnMap["pw_click_at"] = pwClickAt
fnMap["pw_fill"] = pwFill
fnMap["pw_extract_text"] = pwExtractText
fnMap["pw_screenshot"] = pwScreenshot
fnMap["pw_screenshot_and_view"] = pwScreenshotAndView
fnMap["pw_wait_for_selector"] = pwWaitForSelector
fnMap["pw_drag"] = pwDrag
fnMap["pw_get_html"] = pwGetHTML
fnMap["pw_get_dom"] = pwGetDOM
fnMap["pw_search_elements"] = pwSearchElements
playwrightTools := []models.Tool{
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_start",
Description: "Start a Playwright browser instance. Call this first before using other pw_ tools. Uses headless mode by default (set PlaywrightHeadless=false in config for GUI).",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{},
Properties: map[string]models.ToolArgProps{},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_stop",
Description: "Stop the Playwright browser instance. Call when done with browser automation.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{},
Properties: map[string]models.ToolArgProps{},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_is_running",
Description: "Check if Playwright browser is currently running.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{},
Properties: map[string]models.ToolArgProps{},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_navigate",
Description: "Navigate to a URL in the browser.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{"url"},
Properties: map[string]models.ToolArgProps{
"url": models.ToolArgProps{
Type: "string",
Description: "URL to navigate to",
},
},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_click",
Description: "Click on an element using CSS selector. Use 'index' for multiple matches (default 0).",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{"selector"},
Properties: map[string]models.ToolArgProps{
"selector": models.ToolArgProps{
Type: "string",
Description: "CSS selector for the element to click",
},
"index": models.ToolArgProps{
Type: "string",
Description: "optional index for multiple matches (default 0)",
},
},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_fill",
Description: "Fill an input field with text using CSS selector.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{"selector", "text"},
Properties: map[string]models.ToolArgProps{
"selector": models.ToolArgProps{
Type: "string",
Description: "CSS selector for the input element",
},
"text": models.ToolArgProps{
Type: "string",
Description: "text to fill into the input",
},
"index": models.ToolArgProps{
Type: "string",
Description: "optional index for multiple matches (default 0)",
},
},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_extract_text",
Description: "Extract text content from the page or specific elements using CSS selector. Use 'body' for all page text.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{"selector"},
Properties: map[string]models.ToolArgProps{
"selector": models.ToolArgProps{
Type: "string",
Description: "CSS selector (use 'body' for all page text)",
},
},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_screenshot",
Description: "Take a screenshot of the page or a specific element. Returns file path to saved image.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{},
Properties: map[string]models.ToolArgProps{
"selector": models.ToolArgProps{
Type: "string",
Description: "optional CSS selector for element to screenshot",
},
"full_page": models.ToolArgProps{
Type: "string",
Description: "optional: 'true' to capture full page (default false)",
},
},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_screenshot_and_view",
Description: "Take a screenshot and return the image for viewing. Use when model needs to see the screenshot.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{},
Properties: map[string]models.ToolArgProps{
"selector": models.ToolArgProps{
Type: "string",
Description: "optional CSS selector for element to screenshot",
},
"full_page": models.ToolArgProps{
Type: "string",
Description: "optional: 'true' to capture full page (default false)",
},
},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_wait_for_selector",
Description: "Wait for an element to appear on the page.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{"selector"},
Properties: map[string]models.ToolArgProps{
"selector": models.ToolArgProps{
Type: "string",
Description: "CSS selector to wait for",
},
"timeout": models.ToolArgProps{
Type: "string",
Description: "optional timeout in ms (default 30000)",
},
},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_drag",
Description: "Drag the mouse from one point to another.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{"x1", "y1", "x2", "y2"},
Properties: map[string]models.ToolArgProps{
"x1": models.ToolArgProps{
Type: "string",
Description: "starting X coordinate",
},
"y1": models.ToolArgProps{
Type: "string",
Description: "starting Y coordinate",
},
"x2": models.ToolArgProps{
Type: "string",
Description: "ending X coordinate",
},
"y2": models.ToolArgProps{
Type: "string",
Description: "ending Y coordinate",
},
},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_get_html",
Description: "Get the HTML content of the page or a specific element.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{},
Properties: map[string]models.ToolArgProps{
"selector": models.ToolArgProps{
Type: "string",
Description: "optional CSS selector (default: body)",
},
},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_get_dom",
Description: "Get a structured DOM representation of an element with tag, attributes, text, and children.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{},
Properties: map[string]models.ToolArgProps{
"selector": models.ToolArgProps{
Type: "string",
Description: "optional CSS selector (default: body)",
},
},
},
},
},
{
Type: "function",
Function: models.ToolFunc{
Name: "pw_search_elements",
Description: "Search for elements by text content or CSS selector. Returns matching elements with their tags, text, and HTML.",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{},
Properties: map[string]models.ToolArgProps{
"text": models.ToolArgProps{
Type: "string",
Description: "text to search for in elements",
},
"selector": models.ToolArgProps{
Type: "string",
Description: "CSS selector to search for",
},
},
},
},
},
}
baseTools = append(baseTools, playwrightTools...)
toolSysMsg += browserToolSysMsg
}
}
// callToolWithAgent calls the tool and applies any registered agent. // callToolWithAgent calls the tool and applies any registered agent.
func callToolWithAgent(name string, args map[string]string) []byte { func callToolWithAgent(name string, args map[string]string) []byte {
registerWebAgents() registerWebAgents()

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)
}

238
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
@@ -29,6 +29,8 @@ var (
statusLineWidget *tview.TextView statusLineWidget *tview.TextView
helpView *tview.TextView helpView *tview.TextView
flex *tview.Flex flex *tview.Flex
bottomFlex *tview.Flex
notificationWidget *tview.TextView
imgView *tview.Image imgView *tview.Image
defaultImage = "sysprompts/llama.png" defaultImage = "sysprompts/llama.png"
indexPickWindow *tview.InputField indexPickWindow *tview.InputField
@@ -36,6 +38,7 @@ var (
roleEditWindow *tview.InputField roleEditWindow *tview.InputField
shellInput *tview.InputField shellInput *tview.InputField
confirmModal *tview.Modal confirmModal *tview.Modal
toastTimer *time.Timer
confirmPageName = "confirm" confirmPageName = "confirm"
fullscreenMode bool fullscreenMode bool
positionVisible bool = true positionVisible bool = true
@@ -137,6 +140,90 @@ func setShellMode(enabled bool) {
}() }()
} }
// showToast displays a temporary notification in the bottom-right corner.
// It auto-hides after 3 seconds.
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)
if toastTimer != nil {
toastTimer.Stop()
}
// show blocking notification to not mess up flex
if fullscreenMode {
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 2 seconds, since blocking is more annoying
time.AfterFunc(2*time.Second, func() {
app.QueueUpdateDraw(func() {
if pages.HasPage(pageName) {
pages.RemovePage(pageName)
}
})
})
return
}
notificationWidget.SetTitle(title)
notificationWidget.SetText(fmt.Sprintf("[yellow]%s[-]", message))
go func() {
app.QueueUpdateDraw(func() {
flex.RemoveItem(bottomFlex)
flex.RemoveItem(statusLineWidget)
bottomFlex = tview.NewFlex().SetDirection(tview.FlexColumn).
AddItem(textArea, 0, 1, true).
AddItem(notificationWidget, 40, 1, false)
flex.AddItem(bottomFlex, 0, 10, true)
if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false)
}
})
}()
toastTimer = time.AfterFunc(3*time.Second, func() {
app.QueueUpdateDraw(func() {
flex.RemoveItem(bottomFlex)
flex.RemoveItem(statusLineWidget)
bottomFlex = tview.NewFlex().SetDirection(tview.FlexColumn).
AddItem(textArea, 0, 1, true).
AddItem(notificationWidget, 0, 0, false)
flex.AddItem(bottomFlex, 0, 10, true)
if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false)
}
})
})
}
func init() { func init() {
// Start background goroutine to update model color cache // Start background goroutine to update model color cache
startModelColorUpdater() startModelColorUpdater()
@@ -186,7 +273,7 @@ func init() {
shellHistoryPos = -1 shellHistoryPos = -1
} }
// Handle Tab key for @ file completion // Handle Tab key for @ file completion
if event.Key() == tcell.KeyTab { if event.Key() == tcell.KeyTab && shellMode {
currentText := shellInput.GetText() currentText := shellInput.GetText()
atIndex := strings.LastIndex(currentText, "@") atIndex := strings.LastIndex(currentText, "@")
if atIndex >= 0 { if atIndex >= 0 {
@@ -237,12 +324,26 @@ func init() {
SetDynamicColors(true). SetDynamicColors(true).
SetRegions(true). SetRegions(true).
SetChangedFunc(func() { SetChangedFunc(func() {
// INFO:
// https://github.com/rivo/tview/wiki/Concurrency#event-handlers
// although already called by default per tview specs
// calling it explicitly makes text streaming to look more smooth
app.Draw() app.Draw()
}) })
notificationWidget = tview.NewTextView().
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
SetRegions(true).
SetChangedFunc(func() {
})
notificationWidget.SetBorder(true).SetTitle("notification")
bottomFlex = tview.NewFlex().SetDirection(tview.FlexColumn).
AddItem(textArea, 0, 1, true).
AddItem(notificationWidget, 0, 0, false)
// //
flex = tview.NewFlex().SetDirection(tview.FlexRow). flex = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(textView, 0, 40, false). AddItem(textView, 0, 40, false).
AddItem(textArea, 0, 10, true) // Restore original height AddItem(bottomFlex, 0, 10, true)
if positionVisible { if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false) flex.AddItem(statusLineWidget, 0, 2, false)
} }
@@ -311,10 +412,14 @@ func init() {
// y += h / 2 // y += h / 2
// return x, y, w, h // return x, y, w, h
// }) // })
notificationWidget.SetDrawFunc(func(screen tcell.Screen, x, y, w, h int) (int, int, int, int) {
y += h / 2
return x, y, w, h
})
// Initially set up flex without search bar // Initially set up flex without search bar
flex = tview.NewFlex().SetDirection(tview.FlexRow). flex = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(textView, 0, 40, false). AddItem(textView, 0, 40, false).
AddItem(textArea, 0, 10, true) // Restore original height AddItem(bottomFlex, 0, 10, true)
if positionVisible { if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false) flex.AddItem(statusLineWidget, 0, 2, false)
} }
@@ -327,9 +432,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
} }
@@ -359,9 +462,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
} }
@@ -388,9 +489,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)
} }
@@ -401,9 +500,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)
} }
@@ -429,9 +526,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
@@ -463,9 +558,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
}) })
@@ -575,9 +668,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
@@ -594,9 +685,7 @@ 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 return nil
} }
// Handle Ctrl+T to toggle tool call/response visibility // Handle Ctrl+T to toggle tool call/response visibility
@@ -608,9 +697,7 @@ func init() {
if toolCollapsed { if toolCollapsed {
status = "collapsed" status = "collapsed"
} }
if err := notifyUser("tools", "Tool calls/responses "+status); err != nil { showToast("tools", "Tool calls/responses "+status)
logger.Error("failed to send notification", "error", err)
}
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 {
@@ -630,9 +717,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)
@@ -650,9 +735,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]
@@ -678,9 +761,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]
@@ -726,6 +807,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 {
@@ -738,9 +820,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 {
@@ -754,9 +834,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)
@@ -771,9 +849,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{}
@@ -803,9 +879,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 {
@@ -844,9 +918,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)
@@ -859,6 +931,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
} }
@@ -870,21 +943,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
@@ -896,9 +975,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 != "" {
@@ -956,6 +1033,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
@@ -967,26 +1055,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
} }
} }
@@ -1016,9 +1098,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
@@ -1071,6 +1151,18 @@ func init() {
chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: msgText} chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: msgText}
return nil return nil
} }
if event.Key() == tcell.KeyTab && !shellMode {
currentF := app.GetFocus()
if currentF == textArea {
currentText := textArea.GetText()
atIndex := strings.LastIndex(currentText, "@")
if atIndex >= 0 {
filter := currentText[atIndex+1:]
showTextAreaFileCompletionPopup(filter)
}
}
return nil
}
if event.Key() == tcell.KeyPgUp || event.Key() == tcell.KeyPgDn { if event.Key() == tcell.KeyPgUp || event.Key() == tcell.KeyPgDn {
currentF := app.GetFocus() currentF := app.GetFocus()
app.SetFocus(focusSwitcher[currentF]) app.SetFocus(focusSwitcher[currentF])