Compare commits
11 Commits
feat/char-
...
feat/filep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
475936fb1b | ||
|
|
c83779b479 | ||
|
|
43b0fe3739 | ||
|
|
1b36ef938e | ||
|
|
987d5842a4 | ||
|
|
10b665813e | ||
|
|
8c3c2b9b23 | ||
|
|
e42eb96371 | ||
|
|
46a33baabb | ||
|
|
875de679cf | ||
|
|
3b542421e3 |
9
Makefile
9
Makefile
@@ -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
|
||||
go build -tags extra -o gf-lt && ./gf-lt
|
||||
@@ -10,15 +9,9 @@ build-debug:
|
||||
debug: build-debug
|
||||
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
|
||||
go build -tags '!extra' -o gf-lt && ./gf-lt
|
||||
|
||||
noextra-server: setconfig
|
||||
go build -tags '!extra' -o gf-lt && ./gf-lt -port 3333
|
||||
|
||||
setconfig:
|
||||
find config.toml &>/dev/null || cp config.example.toml config.toml
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ made with use of [tview](https://github.com/rivo/tview)
|
||||
- tts/stt (run make commands to get deps);
|
||||
- image input;
|
||||
- function calls (function calls are implemented natively, to avoid calling outside sources);
|
||||
- [character specific context (unique feature)](char-specific-context.md)
|
||||
|
||||
#### how it looks
|
||||

|
||||
|
||||
28
bot.go
28
bot.go
@@ -17,7 +17,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"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
|
||||
func fetchDSBalance() *models.DSBalance {
|
||||
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
|
||||
newMsg = *processMessageTag(&newMsg)
|
||||
chatBody.Messages = append(chatBody.Messages, newMsg)
|
||||
stopTTSIfNotForUser(&newMsg)
|
||||
}
|
||||
cleanChatBody()
|
||||
refreshChatDisplay()
|
||||
|
||||
@@ -113,16 +113,7 @@ When `AutoTurn` is enabled, the system can automatically trigger responses from
|
||||
## Cardmaking with multiple characters
|
||||
|
||||
So far only json format supports multiple characters.
|
||||
Card example:
|
||||
```
|
||||
{
|
||||
"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?"
|
||||
}
|
||||
```
|
||||
[card example](sysprompts/alice_bob_carl.json)
|
||||
|
||||
## Limitations & Caveats
|
||||
|
||||
@@ -131,7 +122,7 @@ Card example:
|
||||
Character‑specific context relies on the `/completion` endpoint (or other completion‑style endpoints) where the LLM is presented with a raw text prompt containing the entire filtered history. It does **not** work with OpenAI‑style `/v1/chat/completions` endpoints, because those endpoints enforce a fixed role set (`user`/`assistant`/`system`) and strip custom role names and metadata.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ type Config struct {
|
||||
DBPATH string `toml:"DBPATH"`
|
||||
FilePickerDir string `toml:"FilePickerDir"`
|
||||
FilePickerExts string `toml:"FilePickerExts"`
|
||||
ImagePreview bool `toml:"ImagePreview"`
|
||||
EnableMouse bool `toml:"EnableMouse"`
|
||||
// embeddings
|
||||
RAGEnabled bool `toml:"RAGEnabled"`
|
||||
|
||||
12
helpfuncs.go
12
helpfuncs.go
@@ -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() {
|
||||
text := textView.GetText(false)
|
||||
quoteReplacer := strings.NewReplacer(
|
||||
|
||||
10
main.go
10
main.go
@@ -1,9 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strconv"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
@@ -20,13 +17,6 @@ var (
|
||||
)
|
||||
|
||||
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)
|
||||
if err := app.SetRoot(pages,
|
||||
true).EnableMouse(cfg.EnableMouse).EnablePaste(true).Run(); err != nil {
|
||||
|
||||
@@ -167,7 +167,6 @@ func (m *RoleMsg) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
func (m *RoleMsg) ToText(i int) string {
|
||||
icon := fmt.Sprintf("(%d)", i)
|
||||
// Convert content to string representation
|
||||
var contentStr string
|
||||
if !m.hasContentParts {
|
||||
@@ -176,9 +175,16 @@ func (m *RoleMsg) ToText(i int) string {
|
||||
// For structured content, just take the text parts
|
||||
var textParts []string
|
||||
for _, part := range m.ContentParts {
|
||||
if partMap, ok := part.(map[string]any); ok {
|
||||
if partType, exists := partMap["type"]; exists && partType == "text" {
|
||||
if textVal, textExists := partMap["text"]; textExists {
|
||||
switch p := part.(type) {
|
||||
case TextContentPart:
|
||||
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 {
|
||||
textParts = append(textParts, textStr)
|
||||
}
|
||||
@@ -193,7 +199,7 @@ func (m *RoleMsg) ToText(i int) string {
|
||||
// since icon and content are separated by \n
|
||||
contentStr, _ = strings.CutPrefix(contentStr, m.Role+":")
|
||||
// if !strings.HasPrefix(contentStr, m.Role+":") {
|
||||
icon = fmt.Sprintf("(%d) <%s>: ", i, m.Role)
|
||||
icon := fmt.Sprintf("(%d) <%s>: ", i, m.Role)
|
||||
// }
|
||||
textMsg := fmt.Sprintf("[-:-:b]%s[-:-:-]\n%s\n", icon, contentStr)
|
||||
return strings.ReplaceAll(textMsg, "\n\n", "\n")
|
||||
@@ -207,9 +213,16 @@ func (m *RoleMsg) ToPrompt() string {
|
||||
// For structured content, just take the text parts
|
||||
var textParts []string
|
||||
for _, part := range m.ContentParts {
|
||||
if partMap, ok := part.(map[string]any); ok {
|
||||
if partType, exists := partMap["type"]; exists && partType == "text" {
|
||||
if textVal, textExists := partMap["text"]; textExists {
|
||||
switch p := part.(type) {
|
||||
case TextContentPart:
|
||||
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 {
|
||||
textParts = append(textParts, textStr)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ func makePropsTable(props map[string]float32) *tview.Table {
|
||||
// Reconfigure the app's mouse setting
|
||||
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) {
|
||||
cfg.AutoTurn = checked
|
||||
})
|
||||
|
||||
74
server.go
74
server.go
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
"filepath": "sysprompts/alice_bob_carl.json",
|
||||
"chars": ["Alice", "Bob", "Carl"],
|
||||
|
||||
227
tables.go
227
tables.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -23,6 +24,15 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
|
||||
chatList[i] = name
|
||||
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
|
||||
rows, cols := len(chatMap)+1, len(actions)+4 // +2 for name, +2 for timestamps
|
||||
chatActTable := tview.NewTable().
|
||||
@@ -779,17 +789,18 @@ func makeFilePicker() *tview.Flex {
|
||||
var selectedFile string
|
||||
// Track currently displayed directory (changes as user navigates)
|
||||
currentDisplayDir := startDir
|
||||
// --- NEW: search state ---
|
||||
searching := false
|
||||
searchQuery := ""
|
||||
// Helper function to check if a file has an allowed extension from config
|
||||
hasAllowedExtension := func(filename string) bool {
|
||||
// If no allowed extensions are specified in config, allow all files
|
||||
if cfg.FilePickerExts == "" {
|
||||
return true
|
||||
}
|
||||
// Split the allowed extensions from the config string
|
||||
allowedExts := strings.Split(cfg.FilePickerExts, ",")
|
||||
lowerFilename := strings.ToLower(strings.TrimSpace(filename))
|
||||
for _, ext := range allowedExts {
|
||||
ext = strings.TrimSpace(ext) // Remove any whitespace around the extension
|
||||
ext = strings.TrimSpace(ext)
|
||||
if ext != "" && strings.HasSuffix(lowerFilename, "."+ext) {
|
||||
return true
|
||||
}
|
||||
@@ -814,16 +825,32 @@ func makeFilePicker() *tview.Flex {
|
||||
statusView := tview.NewTextView()
|
||||
statusView.SetBorder(true).SetTitle("Selected File").SetTitleAlign(tview.AlignLeft)
|
||||
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.AddItem(listView, 0, 3, true)
|
||||
flex.AddItem(hFlex, 0, 3, true)
|
||||
flex.AddItem(statusView, 3, 0, false)
|
||||
// Refresh the file list
|
||||
var refreshList func(string)
|
||||
refreshList = func(dir string) {
|
||||
// Refresh the file list – now accepts a filter string
|
||||
var refreshList func(string, string)
|
||||
refreshList = func(dir string, filter string) {
|
||||
listView.Clear()
|
||||
// Update the current display directory
|
||||
currentDisplayDir = dir // Update the current display directory
|
||||
currentDisplayDir = dir
|
||||
// Add exit option at the top
|
||||
listView.AddItem("Exit file picker [gray](Close without selecting)[-]", "", 'x', func() {
|
||||
pages.RemovePage(filePickerPage)
|
||||
@@ -831,13 +858,16 @@ func makeFilePicker() *tview.Flex {
|
||||
// Add parent directory (..) if not at root
|
||||
if dir != "/" {
|
||||
parentDir := path.Dir(dir)
|
||||
// Special handling for edge cases - only return if we're truly at a system root
|
||||
// For Unix-like systems, path.Dir("/") returns "/" which would cause parentDir == dir
|
||||
if parentDir == dir && dir == "/" {
|
||||
// We're at the root ("/") and trying to go up, just don't add the parent item
|
||||
} else {
|
||||
// For Unix-like systems, avoid infinite loop when at root
|
||||
if parentDir != dir {
|
||||
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)
|
||||
currentStackPos = len(dirStack) - 1
|
||||
})
|
||||
@@ -849,93 +879,182 @@ func makeFilePicker() *tview.Flex {
|
||||
statusView.SetText("Error reading directory: " + err.Error())
|
||||
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 {
|
||||
name := file.Name()
|
||||
// Skip hidden files and directories (those starting with a dot)
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
if file.IsDir() {
|
||||
// Capture the directory name for the closure to avoid loop variable issues
|
||||
if file.IsDir() && matchesFilter(name) {
|
||||
dirName := name
|
||||
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)
|
||||
refreshList(newDir)
|
||||
refreshList(newDir, "")
|
||||
dirStack = append(dirStack, newDir)
|
||||
currentStackPos = len(dirStack) - 1
|
||||
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
|
||||
fullFilePath := path.Join(dir, fileName)
|
||||
listView.AddItem(fileName+" [gray](File)[-]", "", 0, func() {
|
||||
selectedFile = fullFilePath
|
||||
statusView.SetText("Selected: " + selectedFile)
|
||||
// Check if the file is an image
|
||||
if isImageFile(fileName) {
|
||||
// For image files, offer to attach to the next LLM message
|
||||
statusView.SetText("Selected image: " + selectedFile)
|
||||
} else {
|
||||
// For non-image files, display as before
|
||||
statusView.SetText("Selected: " + selectedFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
statusView.SetText("Current: " + dir)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
// 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
|
||||
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() {
|
||||
case tcell.KeyEsc:
|
||||
pages.RemovePage(filePickerPage)
|
||||
return nil
|
||||
case tcell.KeyBackspace2: // Backspace to go to parent directory
|
||||
if cfg.ImagePreview && imgPreview != nil {
|
||||
imgPreview.SetImage(nil)
|
||||
}
|
||||
if currentStackPos > 0 {
|
||||
currentStackPos--
|
||||
prevDir := dirStack[currentStackPos]
|
||||
refreshList(prevDir)
|
||||
// Trim the stack to current position to avoid deep history
|
||||
// Clear search when navigating with backspace
|
||||
searching = false
|
||||
searchQuery = ""
|
||||
refreshList(prevDir, "")
|
||||
// Trim the stack to current position
|
||||
dirStack = dirStack[:currentStackPos+1]
|
||||
}
|
||||
return nil
|
||||
case tcell.KeyRune:
|
||||
if event.Rune() == '/' {
|
||||
// Enter search mode
|
||||
searching = true
|
||||
searchQuery = ""
|
||||
refreshList(currentDisplayDir, "")
|
||||
return nil
|
||||
}
|
||||
case tcell.KeyEnter:
|
||||
// Get the currently highlighted item in the list
|
||||
itemIndex := listView.GetCurrentItem()
|
||||
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)
|
||||
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") {
|
||||
pages.RemovePage(filePickerPage)
|
||||
return nil
|
||||
}
|
||||
// Extract the actual filename/directory name by removing the type info in brackets
|
||||
// Format is "name [gray](type)[-]"
|
||||
// Extract the actual filename/directory name by removing the type info
|
||||
actualItemName := itemText
|
||||
if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 {
|
||||
actualItemName = itemText[:bracketPos]
|
||||
}
|
||||
// Check if it's a directory (ends with /)
|
||||
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
|
||||
if strings.HasPrefix(actualItemName, "../") {
|
||||
// Parent directory - need to go up from current directory
|
||||
// Parent directory
|
||||
targetDir = path.Dir(currentDisplayDir)
|
||||
// Avoid going above root - if parent is same as current and it's system root
|
||||
if targetDir == currentDisplayDir && currentDisplayDir == "/" {
|
||||
// We're at root, don't navigate
|
||||
logger.Warn("went to root", "dir", targetDir)
|
||||
logger.Warn("at root, cannot go up")
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
@@ -943,24 +1062,23 @@ func makeFilePicker() *tview.Flex {
|
||||
dirName := strings.TrimSuffix(actualItemName, "/")
|
||||
targetDir = path.Join(currentDisplayDir, dirName)
|
||||
}
|
||||
// Navigate to the selected directory
|
||||
logger.Info("going to the dir", "dir", targetDir)
|
||||
refreshList(targetDir)
|
||||
// Navigate – clear search
|
||||
logger.Info("going to dir", "dir", targetDir)
|
||||
if cfg.ImagePreview && imgPreview != nil {
|
||||
imgPreview.SetImage(nil)
|
||||
}
|
||||
searching = false
|
||||
searchQuery = ""
|
||||
refreshList(targetDir, "")
|
||||
dirStack = append(dirStack, targetDir)
|
||||
currentStackPos = len(dirStack) - 1
|
||||
statusView.SetText("Current: " + targetDir)
|
||||
return nil
|
||||
} else {
|
||||
// It's a file - construct the full path from current directory and the actual item name
|
||||
// We can't rely only on the selectedFile variable since Enter key might be pressed
|
||||
// without having clicked the file first
|
||||
// It's a file
|
||||
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() {
|
||||
// Check if the file is an image
|
||||
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)
|
||||
SetImageAttachment(filePath)
|
||||
logger.Info("after setting image", "file", actualItemName)
|
||||
@@ -969,7 +1087,6 @@ func makeFilePicker() *tview.Flex {
|
||||
pages.RemovePage(filePickerPage)
|
||||
logger.Info("after update drawn", "file", actualItemName)
|
||||
} else {
|
||||
// For non-image files, update the text area with file path
|
||||
textArea.SetText(filePath, true)
|
||||
app.SetFocus(textArea)
|
||||
pages.RemovePage(filePickerPage)
|
||||
|
||||
1
tools.go
1
tools.go
@@ -330,6 +330,7 @@ func memorise(args map[string]string) []byte {
|
||||
Topic: args["topic"],
|
||||
Mind: args["data"],
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if _, err := store.Memorise(memory); err != nil {
|
||||
logger.Error("failed to save memory", "err", err, "memoory", memory)
|
||||
|
||||
16
tui.go
16
tui.go
@@ -858,7 +858,7 @@ func init() {
|
||||
updateStatusLine()
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyF2 {
|
||||
if event.Key() == tcell.KeyF2 && !botRespMode {
|
||||
// regen last msg
|
||||
if len(chatBody.Messages) == 0 {
|
||||
if err := notifyUser("info", "no messages to regenerate"); err != nil {
|
||||
@@ -871,6 +871,9 @@ func init() {
|
||||
// lastRole := chatBody.Messages[len(chatBody.Messages)-1].Role
|
||||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||||
// go chatRound("", cfg.UserRole, textView, true, false)
|
||||
if cfg.TTS_ENABLED {
|
||||
TTSDoneChan <- true
|
||||
}
|
||||
chatRoundChan <- &models.ChatRoundReq{Role: cfg.UserRole, Regen: true}
|
||||
return nil
|
||||
}
|
||||
@@ -893,6 +896,9 @@ func init() {
|
||||
}
|
||||
chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
|
||||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||||
if cfg.TTS_ENABLED {
|
||||
TTSDoneChan <- true
|
||||
}
|
||||
colorText()
|
||||
return nil
|
||||
}
|
||||
@@ -1120,12 +1126,8 @@ func init() {
|
||||
}
|
||||
}
|
||||
// I need keybind for tts to shut up
|
||||
if event.Key() == tcell.KeyCtrlA {
|
||||
// textArea.SetText("pressed ctrl+A", true)
|
||||
if cfg.TTS_ENABLED {
|
||||
// audioStream.TextChan <- chunk
|
||||
TTSDoneChan <- true
|
||||
}
|
||||
if event.Key() == tcell.KeyCtrlA && cfg.TTS_ENABLED {
|
||||
TTSDoneChan <- true
|
||||
}
|
||||
if event.Key() == tcell.KeyCtrlW {
|
||||
// INFO: continue bot/text message
|
||||
|
||||
Reference in New Issue
Block a user