11 Commits

Author SHA1 Message Date
Grail Finder
475936fb1b Feat: filepicker search 2026-02-17 11:16:52 +03:00
Grail Finder
c83779b479 Doc: add attempts doc 2026-02-16 19:43:14 +03:00
Grail Finder
43b0fe3739 Feat: image preview for filepicker 2026-02-16 19:08:16 +03:00
Grail Finder
1b36ef938e Fix: parsing of content parts 2026-02-16 16:35:06 +03:00
Grail Finder
987d5842a4 Enha: tts.done on regen or delete 2026-02-12 18:16:53 +03:00
Grail Finder
10b665813e Fix: avoid sending regen while bot responding 2026-02-12 16:49:29 +03:00
Grail Finder
8c3c2b9b23 Chore: server should live in separate branch
until a usecase for it is found
2026-02-12 10:26:30 +03:00
Grail Finder
e42eb96371 Doc: update 2026-02-10 11:27:06 +03:00
Grail Finder
46a33baabb Enha: stop tts if msg not for user 2026-02-10 11:25:05 +03:00
Grail Finder
875de679cf Merge branch 'feat/char-secrets' 2026-02-10 11:05:09 +03:00
Grail Finder
3b542421e3 Enha: sort chat table (by updated_at) 2026-01-14 10:06:15 +03:00
14 changed files with 225 additions and 201 deletions

View File

@@ -1,5 +1,4 @@
.PHONY: setconfig run lint setup-whisper build-whisper download-whisper-model docker-up docker-down docker-logs noextra-run noextra-server .PHONY: setconfig run lint setup-whisper build-whisper download-whisper-model docker-up docker-down docker-logs noextra-run
run: setconfig run: setconfig
go build -tags extra -o gf-lt && ./gf-lt go build -tags extra -o gf-lt && ./gf-lt
@@ -10,15 +9,9 @@ build-debug:
debug: build-debug debug: build-debug
dlv exec --headless --accept-multiclient --listen=:2345 ./gf-lt dlv exec --headless --accept-multiclient --listen=:2345 ./gf-lt
server: setconfig
go build -tags extra -o gf-lt && ./gf-lt -port 3333
noextra-run: setconfig noextra-run: setconfig
go build -tags '!extra' -o gf-lt && ./gf-lt go build -tags '!extra' -o gf-lt && ./gf-lt
noextra-server: setconfig
go build -tags '!extra' -o gf-lt && ./gf-lt -port 3333
setconfig: setconfig:
find config.toml &>/dev/null || cp config.example.toml config.toml find config.toml &>/dev/null || cp config.example.toml config.toml

View File

@@ -8,6 +8,7 @@ made with use of [tview](https://github.com/rivo/tview)
- tts/stt (run make commands to get deps); - tts/stt (run make commands to get deps);
- image input; - image input;
- function calls (function calls are implemented natively, to avoid calling outside sources); - function calls (function calls are implemented natively, to avoid calling outside sources);
- [character specific context (unique feature)](char-specific-context.md)
#### how it looks #### how it looks
![how it looks](assets/ex01.png) ![how it looks](assets/ex01.png)

28
bot.go
View File

@@ -17,7 +17,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path"
"regexp" "regexp"
"slices" "slices"
"strconv" "strconv"
@@ -343,32 +342,6 @@ func warmUpModel() {
}() }()
} }
func fetchLCPModelName() *models.LCPModels {
//nolint
resp, err := httpClient.Get(cfg.FetchModelNameAPI)
if err != nil {
chatBody.Model = "disconnected"
logger.Warn("failed to get model", "link", cfg.FetchModelNameAPI, "error", err)
if err := notifyUser("error", "request failed "+cfg.FetchModelNameAPI); err != nil {
logger.Debug("failed to notify user", "error", err, "fn", "fetchLCPModelName")
}
return nil
}
defer resp.Body.Close()
llmModel := models.LCPModels{}
if err := json.NewDecoder(resp.Body).Decode(&llmModel); err != nil {
logger.Warn("failed to decode resp", "link", cfg.FetchModelNameAPI, "error", err)
return nil
}
if resp.StatusCode != 200 {
chatBody.Model = "disconnected"
return nil
}
chatBody.Model = path.Base(llmModel.Data[0].ID)
cfg.CurrentModel = chatBody.Model
return &llmModel
}
// nolint // nolint
func fetchDSBalance() *models.DSBalance { func fetchDSBalance() *models.DSBalance {
url := "https://api.deepseek.com/user/balance" url := "https://api.deepseek.com/user/balance"
@@ -874,6 +847,7 @@ out:
// Process the new message to check for known_to tags in LLM response // Process the new message to check for known_to tags in LLM response
newMsg = *processMessageTag(&newMsg) newMsg = *processMessageTag(&newMsg)
chatBody.Messages = append(chatBody.Messages, newMsg) chatBody.Messages = append(chatBody.Messages, newMsg)
stopTTSIfNotForUser(&newMsg)
} }
cleanChatBody() cleanChatBody()
refreshChatDisplay() refreshChatDisplay()

View File

@@ -113,16 +113,7 @@ When `AutoTurn` is enabled, the system can automatically trigger responses from
## Cardmaking with multiple characters ## Cardmaking with multiple characters
So far only json format supports multiple characters. So far only json format supports multiple characters.
Card example: [card example](sysprompts/alice_bob_carl.json)
```
{
"sys_prompt": "This is a chat between Alice, Bob and Carl. Normally what is said by any character is seen by all others. But characters also might write messages intended to specific targets if their message contain string tag '@{CharName1,CharName2,CharName3}@'.\nFor example:\nAlice:\n\"Hey, Bob. I have a secret for you... (ooc: @Bob@)\"\nThis message would be seen only by Bob and Alice (sender always sees their own message).",
"role": "Alice",
"filepath": "sysprompts/alice_bob_carl.json",
"chars": ["Alice", "Bob", "Carl"],
"first_msg": "Hey guys! Want to play Alias like game? I'll tell Bob a word and he needs to describe that word so Carl can guess what it was?"
}
```
## Limitations & Caveats ## Limitations & Caveats
@@ -131,7 +122,7 @@ Card example:
Characterspecific context relies on the `/completion` endpoint (or other completionstyle endpoints) where the LLM is presented with a raw text prompt containing the entire filtered history. It does **not** work with OpenAIstyle `/v1/chat/completions` endpoints, because those endpoints enforce a fixed role set (`user`/`assistant`/`system`) and strip custom role names and metadata. Characterspecific context relies on the `/completion` endpoint (or other completionstyle endpoints) where the LLM is presented with a raw text prompt containing the entire filtered history. It does **not** work with OpenAIstyle `/v1/chat/completions` endpoints, because those endpoints enforce a fixed role set (`user`/`assistant`/`system`) and strip custom role names and metadata.
### TTS ### TTS
Although text message might be hidden from user character. If TTS is enabled it will be read. Although text message might be hidden from user character. If TTS is enabled it will be read until tags are parsed. If message should not be viewed by user, tts will stop.
### Tag Parsing ### Tag Parsing

View File

@@ -30,6 +30,7 @@ type Config struct {
DBPATH string `toml:"DBPATH"` DBPATH string `toml:"DBPATH"`
FilePickerDir string `toml:"FilePickerDir"` FilePickerDir string `toml:"FilePickerDir"`
FilePickerExts string `toml:"FilePickerExts"` FilePickerExts string `toml:"FilePickerExts"`
ImagePreview bool `toml:"ImagePreview"`
EnableMouse bool `toml:"EnableMouse"` EnableMouse bool `toml:"EnableMouse"`
// embeddings // embeddings
RAGEnabled bool `toml:"RAGEnabled"` RAGEnabled bool `toml:"RAGEnabled"`

View File

@@ -45,6 +45,18 @@ func refreshChatDisplay() {
}) })
} }
func stopTTSIfNotForUser(msg *models.RoleMsg) {
viewingAs := cfg.UserRole
if cfg.WriteNextMsgAs != "" {
viewingAs = cfg.WriteNextMsgAs
}
// stop tts if msg is not for user
if cfg.CharSpecificContextEnabled &&
!slices.Contains(msg.KnownTo, viewingAs) && cfg.TTS_ENABLED {
TTSDoneChan <- true
}
}
func colorText() { func colorText() {
text := textView.GetText(false) text := textView.GetText(false)
quoteReplacer := strings.NewReplacer( quoteReplacer := strings.NewReplacer(

10
main.go
View File

@@ -1,9 +1,6 @@
package main package main
import ( import (
"flag"
"strconv"
"github.com/rivo/tview" "github.com/rivo/tview"
) )
@@ -20,13 +17,6 @@ var (
) )
func main() { func main() {
apiPort := flag.Int("port", 0, "port to host api")
flag.Parse()
if apiPort != nil && *apiPort > 3000 {
srv := Server{}
srv.ListenToRequests(strconv.Itoa(*apiPort))
return
}
pages.AddPage("main", flex, true, true) pages.AddPage("main", flex, true, true)
if err := app.SetRoot(pages, if err := app.SetRoot(pages,
true).EnableMouse(cfg.EnableMouse).EnablePaste(true).Run(); err != nil { true).EnableMouse(cfg.EnableMouse).EnablePaste(true).Run(); err != nil {

View File

@@ -167,7 +167,6 @@ func (m *RoleMsg) UnmarshalJSON(data []byte) error {
} }
func (m *RoleMsg) ToText(i int) string { func (m *RoleMsg) ToText(i int) string {
icon := fmt.Sprintf("(%d)", i)
// Convert content to string representation // Convert content to string representation
var contentStr string var contentStr string
if !m.hasContentParts { if !m.hasContentParts {
@@ -176,9 +175,16 @@ func (m *RoleMsg) ToText(i int) string {
// For structured content, just take the text parts // For structured content, just take the text parts
var textParts []string var textParts []string
for _, part := range m.ContentParts { for _, part := range m.ContentParts {
if partMap, ok := part.(map[string]any); ok { switch p := part.(type) {
if partType, exists := partMap["type"]; exists && partType == "text" { case TextContentPart:
if textVal, textExists := partMap["text"]; textExists { if p.Type == "text" {
textParts = append(textParts, p.Text)
}
case ImageContentPart:
// skip images for text display
case map[string]any:
if partType, exists := p["type"]; exists && partType == "text" {
if textVal, textExists := p["text"]; textExists {
if textStr, isStr := textVal.(string); isStr { if textStr, isStr := textVal.(string); isStr {
textParts = append(textParts, textStr) textParts = append(textParts, textStr)
} }
@@ -193,7 +199,7 @@ func (m *RoleMsg) ToText(i int) string {
// since icon and content are separated by \n // since icon and content are separated by \n
contentStr, _ = strings.CutPrefix(contentStr, m.Role+":") contentStr, _ = strings.CutPrefix(contentStr, m.Role+":")
// if !strings.HasPrefix(contentStr, m.Role+":") { // if !strings.HasPrefix(contentStr, m.Role+":") {
icon = fmt.Sprintf("(%d) <%s>: ", i, m.Role) icon := fmt.Sprintf("(%d) <%s>: ", i, m.Role)
// } // }
textMsg := fmt.Sprintf("[-:-:b]%s[-:-:-]\n%s\n", icon, contentStr) textMsg := fmt.Sprintf("[-:-:b]%s[-:-:-]\n%s\n", icon, contentStr)
return strings.ReplaceAll(textMsg, "\n\n", "\n") return strings.ReplaceAll(textMsg, "\n\n", "\n")
@@ -207,9 +213,16 @@ func (m *RoleMsg) ToPrompt() string {
// For structured content, just take the text parts // For structured content, just take the text parts
var textParts []string var textParts []string
for _, part := range m.ContentParts { for _, part := range m.ContentParts {
if partMap, ok := part.(map[string]any); ok { switch p := part.(type) {
if partType, exists := partMap["type"]; exists && partType == "text" { case TextContentPart:
if textVal, textExists := partMap["text"]; textExists { if p.Type == "text" {
textParts = append(textParts, p.Text)
}
case ImageContentPart:
// skip images for text display
case map[string]any:
if partType, exists := p["type"]; exists && partType == "text" {
if textVal, textExists := p["text"]; textExists {
if textStr, isStr := textVal.(string); isStr { if textStr, isStr := textVal.(string); isStr {
textParts = append(textParts, textStr) textParts = append(textParts, textStr)
} }

View File

@@ -135,6 +135,9 @@ func makePropsTable(props map[string]float32) *tview.Table {
// Reconfigure the app's mouse setting // Reconfigure the app's mouse setting
app.EnableMouse(cfg.EnableMouse) app.EnableMouse(cfg.EnableMouse)
}) })
addCheckboxRow("Image Preview (file picker)", cfg.ImagePreview, func(checked bool) {
cfg.ImagePreview = checked
})
addCheckboxRow("Auto turn (for cards with many chars)", cfg.AutoTurn, func(checked bool) { addCheckboxRow("Auto turn (for cards with many chars)", cfg.AutoTurn, func(checked bool) {
cfg.AutoTurn = checked cfg.AutoTurn = checked
}) })

View File

@@ -1,74 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"gf-lt/config"
"net/http"
"time"
)
type Server struct {
// nolint
config config.Config
}
func (srv *Server) ListenToRequests(port string) {
// h := srv.actions
mux := http.NewServeMux()
server := &http.Server{
Addr: "localhost:" + port,
Handler: mux,
ReadTimeout: time.Second * 5,
WriteTimeout: time.Second * 5,
}
mux.HandleFunc("GET /ping", pingHandler)
mux.HandleFunc("GET /model", modelHandler)
mux.HandleFunc("POST /completion", completionHandler)
fmt.Println("Listening", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil {
panic(err)
}
}
// create server
// listen to the completion endpoint handler
func pingHandler(w http.ResponseWriter, req *http.Request) {
if _, err := w.Write([]byte("pong")); err != nil {
logger.Error("server ping", "error", err)
}
}
func completionHandler(w http.ResponseWriter, req *http.Request) {
// post request
body := req.Body
// get body as io.reader
// pass it to the /completion
go sendMsgToLLM(body)
out:
for {
select {
case chunk := <-chunkChan:
fmt.Print(chunk)
if _, err := w.Write([]byte(chunk)); err != nil {
logger.Warn("failed to write chunk", "value", chunk)
continue
}
case <-streamDone:
break out
}
}
}
func modelHandler(w http.ResponseWriter, req *http.Request) {
llmModel := fetchLCPModelName()
payload, err := json.Marshal(llmModel)
if err != nil {
logger.Error("model handler", "error", err)
// return err
return
}
if _, err := w.Write(payload); err != nil {
logger.Error("model handler", "error", err)
}
}

View File

@@ -1,5 +1,5 @@
{ {
"sys_prompt": "This is a chat between Alice, Bob and Carl. Normally all message are public (seen by everyone). But characters also able to make messages intended to specific targets using '@' tag. Usually tag is provided inside of out of character clause: (ooc: @charname@), but will be parsed if put anywhere in the message.\nTO SEND A PRIVATE MESSAGE:\n- Include a recipient tag in this exact format: @CharacterName@\n- The tag can be anywhere in your message\n- Example: \"Don't tell others this secret. (ooc: @Bob@)\"\n- For immersion sake it is better if private messages are given in context of whispering, passing notes, or being alone in some space: Alice: *leans closer to Carl and whispers* \"I forgot to turn off the car, could you watch my bag for a cuple of minutes? (ooc: @Carl@)\"\n- Only the sender and tagged recipients will see that message.\nRECEIVING MESSAGES:\n- You only see messages where you are the sender OR you are tagged in the recipient tag\n- Public messages (without tags) are seen by everyone.\nEXAMPLE FORMAT:\nAlice: \"Public message everyone sees\"\nAlice: \"Private message only for Bob @Bob@\"\n(if Diana joins the conversation, and Alice wants to exclude her) Alice: *Grabs Bob and Carl, and pulls them away* \"Listen boys, let's meet this friday again!\" (ooc: @Bob,Carl@; Diana is not trustworthy)\nWHEN TO USE:\n- Most of the time public messages (no tag) are the best choice. Private messages (with tag) are mostly for the passing secrets or information that is described or infered as private.\n- Game of 20 questions. Guys are putting paper sickers on the forehead with names written on them. So in this case only person who gets the sticker put on them does not see the writting on it.\nBob: *Puts sticker with 'JACK THE RIPPER' written on it, on Alices forehead* (ooc: @Carl).\nCarl: \"Alright, we're ready.\"\nAlice: \"Good. So, am I a fictional character or a real one?\"", "sys_prompt": "This is a chat between Alice, Bob and Carl. Normally all message are public (seen by everyone). But characters also able to make messages intended to specific targets using '@' tag. Usually tag is provided inside of out of character clause: (ooc: @charname@), but will be parsed if put anywhere in the message.\nTO SEND A PRIVATE MESSAGE:\n- Include a recipient tag in this exact format: @CharacterName@\n- The tag can be anywhere in your message\n- Example: \"(ooc: @Bob@) Don't tell others this secret.\"\n- For immersion sake it is better if private messages are given in context of whispering, passing notes, or being alone in some space: Alice: (ooc: @Carl@) *leans closer to Carl and whispers* \"I forgot to turn off the car, could you watch my bag for a cuple of minutes?\"\n- Only the sender and tagged recipients will see that message.\nRECEIVING MESSAGES:\n- You only see messages where you are the sender OR you are tagged in the recipient tag\n- Public messages (without tags) are seen by everyone.\nEXAMPLE FORMAT:\nAlice: \"Public message everyone sees\"\nAlice: (ooc: @Bob@)\n\"Private message only for Bob\"\n(if Diana joins the conversation, and Alice wants to exclude her) Alice: (ooc: @Bob,Carl@; Diana is not trustworthy)\n*Grabs Bob and Carl, and pulls them away* \"Listen boys, let's meet this friday again!\"\nWHEN TO USE:\n- Most of the time public messages (no tag) are the best choice. Private messages (with tag) are mostly for the passing secrets or information that is described or infered as private.\n- Game of 20 questions. Guys are putting paper sickers on the forehead with names written on them. So in this case only person who gets the sticker put on them does not see the writting on it.\nBob: *Puts sticker with 'JACK THE RIPPER' written on it, on Alices forehead* (ooc: @Carl).\nCarl: \"Alright, we're ready.\"\nAlice: \"Good. So, am I a fictional character or a real one?\"",
"role": "Alice", "role": "Alice",
"filepath": "sysprompts/alice_bob_carl.json", "filepath": "sysprompts/alice_bob_carl.json",
"chars": ["Alice", "Bob", "Carl"], "chars": ["Alice", "Bob", "Carl"],

225
tables.go
View File

@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"image"
"os" "os"
"path" "path"
"strings" "strings"
@@ -23,6 +24,15 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
chatList[i] = name chatList[i] = name
i++ i++
} }
// Sort chatList by UpdatedAt field in descending order (most recent first)
for i := 0; i < len(chatList)-1; i++ {
for j := i + 1; j < len(chatList); j++ {
if chatMap[chatList[i]].UpdatedAt.Before(chatMap[chatList[j]].UpdatedAt) {
// Swap chatList[i] and chatList[j]
chatList[i], chatList[j] = chatList[j], chatList[i]
}
}
}
// Add 1 extra row for header // Add 1 extra row for header
rows, cols := len(chatMap)+1, len(actions)+4 // +2 for name, +2 for timestamps rows, cols := len(chatMap)+1, len(actions)+4 // +2 for name, +2 for timestamps
chatActTable := tview.NewTable(). chatActTable := tview.NewTable().
@@ -779,17 +789,18 @@ func makeFilePicker() *tview.Flex {
var selectedFile string var selectedFile string
// Track currently displayed directory (changes as user navigates) // Track currently displayed directory (changes as user navigates)
currentDisplayDir := startDir currentDisplayDir := startDir
// --- NEW: search state ---
searching := false
searchQuery := ""
// Helper function to check if a file has an allowed extension from config // Helper function to check if a file has an allowed extension from config
hasAllowedExtension := func(filename string) bool { hasAllowedExtension := func(filename string) bool {
// If no allowed extensions are specified in config, allow all files
if cfg.FilePickerExts == "" { if cfg.FilePickerExts == "" {
return true return true
} }
// Split the allowed extensions from the config string
allowedExts := strings.Split(cfg.FilePickerExts, ",") allowedExts := strings.Split(cfg.FilePickerExts, ",")
lowerFilename := strings.ToLower(strings.TrimSpace(filename)) lowerFilename := strings.ToLower(strings.TrimSpace(filename))
for _, ext := range allowedExts { for _, ext := range allowedExts {
ext = strings.TrimSpace(ext) // Remove any whitespace around the extension ext = strings.TrimSpace(ext)
if ext != "" && strings.HasSuffix(lowerFilename, "."+ext) { if ext != "" && strings.HasSuffix(lowerFilename, "."+ext) {
return true return true
} }
@@ -814,16 +825,32 @@ func makeFilePicker() *tview.Flex {
statusView := tview.NewTextView() statusView := tview.NewTextView()
statusView.SetBorder(true).SetTitle("Selected File").SetTitleAlign(tview.AlignLeft) statusView.SetBorder(true).SetTitle("Selected File").SetTitleAlign(tview.AlignLeft)
statusView.SetTextColor(tcell.ColorYellow) statusView.SetTextColor(tcell.ColorYellow)
// Layout - only include list view and status view // Image preview pane
var imgPreview *tview.Image
if cfg.ImagePreview {
imgPreview = tview.NewImage()
imgPreview.SetBorder(true).SetTitle("Preview").SetTitleAlign(tview.AlignLeft)
}
// Horizontal flex for list + preview
var hFlex *tview.Flex
if cfg.ImagePreview && imgPreview != nil {
hFlex = tview.NewFlex().SetDirection(tview.FlexColumn).
AddItem(listView, 0, 3, true).
AddItem(imgPreview, 0, 2, false)
} else {
hFlex = tview.NewFlex().SetDirection(tview.FlexColumn).
AddItem(listView, 0, 1, true)
}
// Main vertical flex
flex := tview.NewFlex().SetDirection(tview.FlexRow) flex := tview.NewFlex().SetDirection(tview.FlexRow)
flex.AddItem(listView, 0, 3, true) flex.AddItem(hFlex, 0, 3, true)
flex.AddItem(statusView, 3, 0, false) flex.AddItem(statusView, 3, 0, false)
// Refresh the file list // Refresh the file list now accepts a filter string
var refreshList func(string) var refreshList func(string, string)
refreshList = func(dir string) { refreshList = func(dir string, filter string) {
listView.Clear() listView.Clear()
// Update the current display directory // Update the current display directory
currentDisplayDir = dir // Update the current display directory currentDisplayDir = dir
// Add exit option at the top // Add exit option at the top
listView.AddItem("Exit file picker [gray](Close without selecting)[-]", "", 'x', func() { listView.AddItem("Exit file picker [gray](Close without selecting)[-]", "", 'x', func() {
pages.RemovePage(filePickerPage) pages.RemovePage(filePickerPage)
@@ -831,13 +858,16 @@ func makeFilePicker() *tview.Flex {
// Add parent directory (..) if not at root // Add parent directory (..) if not at root
if dir != "/" { if dir != "/" {
parentDir := path.Dir(dir) parentDir := path.Dir(dir)
// Special handling for edge cases - only return if we're truly at a system root // For Unix-like systems, avoid infinite loop when at root
// For Unix-like systems, path.Dir("/") returns "/" which would cause parentDir == dir if parentDir != dir {
if parentDir == dir && dir == "/" {
// We're at the root ("/") and trying to go up, just don't add the parent item
} else {
listView.AddItem("../ [gray](Parent Directory)[-]", "", 'p', func() { listView.AddItem("../ [gray](Parent Directory)[-]", "", 'p', func() {
refreshList(parentDir) // Clear search on navigation
searching = false
searchQuery = ""
if cfg.ImagePreview {
imgPreview.SetImage(nil)
}
refreshList(parentDir, "")
dirStack = append(dirStack, parentDir) dirStack = append(dirStack, parentDir)
currentStackPos = len(dirStack) - 1 currentStackPos = len(dirStack) - 1
}) })
@@ -849,93 +879,182 @@ func makeFilePicker() *tview.Flex {
statusView.SetText("Error reading directory: " + err.Error()) statusView.SetText("Error reading directory: " + err.Error())
return return
} }
// Add directories and files to the list // Helper to check if an item passes the filter
matchesFilter := func(name string) bool {
if filter == "" {
return true
}
return strings.Contains(strings.ToLower(name), strings.ToLower(filter))
}
// Add directories
for _, file := range files { for _, file := range files {
name := file.Name() name := file.Name()
// Skip hidden files and directories (those starting with a dot)
if strings.HasPrefix(name, ".") { if strings.HasPrefix(name, ".") {
continue continue
} }
if file.IsDir() { if file.IsDir() && matchesFilter(name) {
// Capture the directory name for the closure to avoid loop variable issues
dirName := name dirName := name
listView.AddItem(dirName+"/ [gray](Directory)[-]", "", 0, func() { listView.AddItem(dirName+"/ [gray](Directory)[-]", "", 0, func() {
// Clear search on navigation
searching = false
searchQuery = ""
if cfg.ImagePreview {
imgPreview.SetImage(nil)
}
newDir := path.Join(dir, dirName) newDir := path.Join(dir, dirName)
refreshList(newDir) refreshList(newDir, "")
dirStack = append(dirStack, newDir) dirStack = append(dirStack, newDir)
currentStackPos = len(dirStack) - 1 currentStackPos = len(dirStack) - 1
statusView.SetText("Current: " + newDir) statusView.SetText("Current: " + newDir)
}) })
} else if hasAllowedExtension(name) { }
// Only show files that have allowed extensions (from config) }
// Capture the file name for the closure to avoid loop variable issues // Add files with allowed extensions
for _, file := range files {
name := file.Name()
if strings.HasPrefix(name, ".") || file.IsDir() {
continue
}
if hasAllowedExtension(name) && matchesFilter(name) {
fileName := name fileName := name
fullFilePath := path.Join(dir, fileName) fullFilePath := path.Join(dir, fileName)
listView.AddItem(fileName+" [gray](File)[-]", "", 0, func() { listView.AddItem(fileName+" [gray](File)[-]", "", 0, func() {
selectedFile = fullFilePath selectedFile = fullFilePath
statusView.SetText("Selected: " + selectedFile) statusView.SetText("Selected: " + selectedFile)
// Check if the file is an image
if isImageFile(fileName) { if isImageFile(fileName) {
// For image files, offer to attach to the next LLM message
statusView.SetText("Selected image: " + selectedFile) statusView.SetText("Selected image: " + selectedFile)
} else {
// For non-image files, display as before
statusView.SetText("Selected: " + selectedFile)
} }
}) })
} }
} }
// Update status line based on search state
if searching {
statusView.SetText("Search: " + searchQuery + "_")
} else if searchQuery != "" {
statusView.SetText("Current: " + dir + " (filter: " + searchQuery + ")")
} else {
statusView.SetText("Current: " + dir) statusView.SetText("Current: " + dir)
} }
}
// Initialize the file list // Initialize the file list
refreshList(startDir) refreshList(startDir, "")
// Update image preview when selection changes (unchanged)
if cfg.ImagePreview && imgPreview != nil {
listView.SetChangedFunc(func(index int, mainText, secondaryText string, rune rune) {
itemText, _ := listView.GetItemText(index)
if strings.HasPrefix(itemText, "Exit file picker") || strings.HasPrefix(itemText, "../") {
imgPreview.SetImage(nil)
return
}
actualItemName := itemText
if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 {
actualItemName = itemText[:bracketPos]
}
if strings.HasSuffix(actualItemName, "/") {
imgPreview.SetImage(nil)
return
}
if !isImageFile(actualItemName) {
imgPreview.SetImage(nil)
return
}
filePath := path.Join(currentDisplayDir, actualItemName)
file, err := os.Open(filePath)
if err != nil {
imgPreview.SetImage(nil)
return
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
imgPreview.SetImage(nil)
return
}
imgPreview.SetImage(img)
})
}
// Set up keyboard navigation // Set up keyboard navigation
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
// --- Handle search mode ---
if searching {
switch event.Key() {
case tcell.KeyEsc:
// Exit search, clear filter
searching = false
searchQuery = ""
refreshList(currentDisplayDir, "")
return nil
case tcell.KeyBackspace, tcell.KeyBackspace2:
if len(searchQuery) > 0 {
searchQuery = searchQuery[:len(searchQuery)-1]
refreshList(currentDisplayDir, searchQuery)
}
return nil
case tcell.KeyRune:
r := event.Rune()
if r != 0 {
searchQuery += string(r)
refreshList(currentDisplayDir, searchQuery)
}
return nil
default:
// Pass all other keys (arrows, Enter, etc.) to normal processing
// This allows selecting items while still in search mode
return event
}
}
// --- Not searching ---
switch event.Key() { switch event.Key() {
case tcell.KeyEsc: case tcell.KeyEsc:
pages.RemovePage(filePickerPage) pages.RemovePage(filePickerPage)
return nil return nil
case tcell.KeyBackspace2: // Backspace to go to parent directory case tcell.KeyBackspace2: // Backspace to go to parent directory
if cfg.ImagePreview && imgPreview != nil {
imgPreview.SetImage(nil)
}
if currentStackPos > 0 { if currentStackPos > 0 {
currentStackPos-- currentStackPos--
prevDir := dirStack[currentStackPos] prevDir := dirStack[currentStackPos]
refreshList(prevDir) // Clear search when navigating with backspace
// Trim the stack to current position to avoid deep history searching = false
searchQuery = ""
refreshList(prevDir, "")
// Trim the stack to current position
dirStack = dirStack[:currentStackPos+1] dirStack = dirStack[:currentStackPos+1]
} }
return nil return nil
case tcell.KeyRune:
if event.Rune() == '/' {
// Enter search mode
searching = true
searchQuery = ""
refreshList(currentDisplayDir, "")
return nil
}
case tcell.KeyEnter: case tcell.KeyEnter:
// Get the currently highlighted item in the list // Get the currently highlighted item in the list
itemIndex := listView.GetCurrentItem() itemIndex := listView.GetCurrentItem()
if itemIndex >= 0 && itemIndex < listView.GetItemCount() { if itemIndex >= 0 && itemIndex < listView.GetItemCount() {
// We need to get the text of the currently selected item to determine if it's a directory
// Since we can't directly get the item text, we'll keep track of items differently
// Let's improve the approach by tracking the currently selected item
itemText, _ := listView.GetItemText(itemIndex) itemText, _ := listView.GetItemText(itemIndex)
logger.Info("choosing dir", "itemText", itemText) logger.Info("choosing dir", "itemText", itemText)
// Check for the exit option first (should be the first item) // Check for the exit option first
if strings.HasPrefix(itemText, "Exit file picker") { if strings.HasPrefix(itemText, "Exit file picker") {
pages.RemovePage(filePickerPage) pages.RemovePage(filePickerPage)
return nil return nil
} }
// Extract the actual filename/directory name by removing the type info in brackets // Extract the actual filename/directory name by removing the type info
// Format is "name [gray](type)[-]"
actualItemName := itemText actualItemName := itemText
if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 { if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 {
actualItemName = itemText[:bracketPos] actualItemName = itemText[:bracketPos]
} }
// Check if it's a directory (ends with /) // Check if it's a directory (ends with /)
if strings.HasSuffix(actualItemName, "/") { if strings.HasSuffix(actualItemName, "/") {
// This is a directory, we need to get the full path
// Since the item text ends with "/" and represents a directory
var targetDir string var targetDir string
if strings.HasPrefix(actualItemName, "../") { if strings.HasPrefix(actualItemName, "../") {
// Parent directory - need to go up from current directory // Parent directory
targetDir = path.Dir(currentDisplayDir) targetDir = path.Dir(currentDisplayDir)
// Avoid going above root - if parent is same as current and it's system root
if targetDir == currentDisplayDir && currentDisplayDir == "/" { if targetDir == currentDisplayDir && currentDisplayDir == "/" {
// We're at root, don't navigate logger.Warn("at root, cannot go up")
logger.Warn("went to root", "dir", targetDir)
return nil return nil
} }
} else { } else {
@@ -943,24 +1062,23 @@ func makeFilePicker() *tview.Flex {
dirName := strings.TrimSuffix(actualItemName, "/") dirName := strings.TrimSuffix(actualItemName, "/")
targetDir = path.Join(currentDisplayDir, dirName) targetDir = path.Join(currentDisplayDir, dirName)
} }
// Navigate to the selected directory // Navigate clear search
logger.Info("going to the dir", "dir", targetDir) logger.Info("going to dir", "dir", targetDir)
refreshList(targetDir) if cfg.ImagePreview && imgPreview != nil {
imgPreview.SetImage(nil)
}
searching = false
searchQuery = ""
refreshList(targetDir, "")
dirStack = append(dirStack, targetDir) dirStack = append(dirStack, targetDir)
currentStackPos = len(dirStack) - 1 currentStackPos = len(dirStack) - 1
statusView.SetText("Current: " + targetDir) statusView.SetText("Current: " + targetDir)
return nil return nil
} else { } else {
// It's a file - construct the full path from current directory and the actual item name // It's a file
// We can't rely only on the selectedFile variable since Enter key might be pressed
// without having clicked the file first
filePath := path.Join(currentDisplayDir, actualItemName) filePath := path.Join(currentDisplayDir, actualItemName)
// Verify it's actually a file (not just lacking a directory suffix)
if info, err := os.Stat(filePath); err == nil && !info.IsDir() { if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
// Check if the file is an image
if isImageFile(actualItemName) { if isImageFile(actualItemName) {
// For image files, set it as an attachment for the next LLM message
// Use the version without UI updates to avoid hangs in event handlers
logger.Info("setting image", "file", actualItemName) logger.Info("setting image", "file", actualItemName)
SetImageAttachment(filePath) SetImageAttachment(filePath)
logger.Info("after setting image", "file", actualItemName) logger.Info("after setting image", "file", actualItemName)
@@ -969,7 +1087,6 @@ func makeFilePicker() *tview.Flex {
pages.RemovePage(filePickerPage) pages.RemovePage(filePickerPage)
logger.Info("after update drawn", "file", actualItemName) logger.Info("after update drawn", "file", actualItemName)
} else { } else {
// For non-image files, update the text area with file path
textArea.SetText(filePath, true) textArea.SetText(filePath, true)
app.SetFocus(textArea) app.SetFocus(textArea)
pages.RemovePage(filePickerPage) pages.RemovePage(filePickerPage)

View File

@@ -330,6 +330,7 @@ func memorise(args map[string]string) []byte {
Topic: args["topic"], Topic: args["topic"],
Mind: args["data"], Mind: args["data"],
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
CreatedAt: time.Now(),
} }
if _, err := store.Memorise(memory); err != nil { if _, err := store.Memorise(memory); err != nil {
logger.Error("failed to save memory", "err", err, "memoory", memory) logger.Error("failed to save memory", "err", err, "memoory", memory)

14
tui.go
View File

@@ -858,7 +858,7 @@ func init() {
updateStatusLine() updateStatusLine()
return nil return nil
} }
if event.Key() == tcell.KeyF2 { 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 { if err := notifyUser("info", "no messages to regenerate"); err != nil {
@@ -871,6 +871,9 @@ func init() {
// lastRole := chatBody.Messages[len(chatBody.Messages)-1].Role // lastRole := chatBody.Messages[len(chatBody.Messages)-1].Role
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
// go chatRound("", cfg.UserRole, textView, true, false) // go chatRound("", cfg.UserRole, textView, true, false)
if cfg.TTS_ENABLED {
TTSDoneChan <- true
}
chatRoundChan <- &models.ChatRoundReq{Role: cfg.UserRole, Regen: true} chatRoundChan <- &models.ChatRoundReq{Role: cfg.UserRole, Regen: true}
return nil return nil
} }
@@ -893,6 +896,9 @@ func init() {
} }
chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1] chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
if cfg.TTS_ENABLED {
TTSDoneChan <- true
}
colorText() colorText()
return nil return nil
} }
@@ -1120,13 +1126,9 @@ func init() {
} }
} }
// I need keybind for tts to shut up // I need keybind for tts to shut up
if event.Key() == tcell.KeyCtrlA { if event.Key() == tcell.KeyCtrlA && cfg.TTS_ENABLED {
// textArea.SetText("pressed ctrl+A", true)
if cfg.TTS_ENABLED {
// audioStream.TextChan <- chunk
TTSDoneChan <- true TTSDoneChan <- true
} }
}
if event.Key() == tcell.KeyCtrlW { if event.Key() == tcell.KeyCtrlW {
// INFO: continue bot/text message // INFO: continue bot/text message
// without new role // without new role