10 Commits

Author SHA1 Message Date
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
15 changed files with 192 additions and 148 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"`

66
docs/filepicker-search.md Normal file
View File

@@ -0,0 +1,66 @@
# Filepicker Search Implementation - Notes
## Goal
Add `/` key functionality in filepicker (Ctrl+O) to filter/search files by name, similar to how `/` works in the main TUI textview.
## Requirements
- Press `/` to activate search mode
- Live case-insensitive filtering
- `../` (parent directory) always visible
- Show "No matching files" when nothing matches
- Esc to cancel (return to main app for sending messages)
- Enter to confirm search and close search input
## Approaches Tried
### Approach 1: Modify Flex Layout In-Place
Add search input to the existing flex container by replacing listView with searchInput.
**Issues:**
- tview's `RemoveItem`/`AddItem` causes UI freezes/hangs
- Using `app.QueueUpdate` or `app.Draw` didn't help
- Layout changes don't render properly
### Approach 2: Add Input Capture to ListView
Handle `/` key in listView's SetInputCapture.
**Issues:**
- Key events don't reach listView when filepicker is open
- Global app input capture handles `/` for main textview search first
- Even when checking `pages.GetFrontPage()`, the key isn't captured
### Approach 3: Global Handler with Page Replacement
Handle `/` in global app input capture when filepicker page is frontmost.
**Issues:**
- Search input appears but text is invisible (color issues)
- Enter/Esc not handled - main TUI captures them
- Creating new pages adds on top instead of replacing, causing split-screen effect
- Enter on file item opens new filepicker (page stacking issue)
### Approach 4: Overlay Page (Modal-style)
Create a new flex with search input on top and filepicker below, replace the page.
**Issues:**
- Page replacement causes split-screen between main app and filepicker
- Search input renders but invisible text
- Enter/Esc handled by main TUI, not search input
- State lost when recreating filepicker
## Root Causes
1. **tview UI update issues**: Direct manipulation of flex items causes freezes or doesn't render
2. **Input capture priority**: Even with page overlay, main TUI's global input capture processes keys first
3. **Esc key conflict**: Esc is used for sending messages in main TUI, and it's hard to distinguish when filepicker is open
4. **Focus management**: tview's focus system doesn't work as expected with dynamic layouts
## Possible Solutions (Not Tried)
1. **Use tview's built-in Filter method**: ListView has a SetFilterFunc that might work
2. **Create separate search primitive**: Instead of replacing list, use a separate text input overlay
3. **Different key for search**: Use a key that isn't already mapped in main TUI
4. **Fork/extend tview**: May need to modify tview itself for better dynamic UI updates
5. **Use form with text input**: tview.Forms might handle input better
## Current State
All search-related changes rolled back. Filepicker works as before without search functionality.

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"],

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().
@@ -814,9 +824,25 @@ 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
var refreshList func(string) var refreshList func(string)
@@ -837,6 +863,7 @@ func makeFilePicker() *tview.Flex {
// We're at the root ("/") and trying to go up, just don't add the parent item // We're at the root ("/") and trying to go up, just don't add the parent item
} else { } else {
listView.AddItem("../ [gray](Parent Directory)[-]", "", 'p', func() { listView.AddItem("../ [gray](Parent Directory)[-]", "", 'p', func() {
imgPreview.SetImage(nil)
refreshList(parentDir) refreshList(parentDir)
dirStack = append(dirStack, parentDir) dirStack = append(dirStack, parentDir)
currentStackPos = len(dirStack) - 1 currentStackPos = len(dirStack) - 1
@@ -860,6 +887,7 @@ func makeFilePicker() *tview.Flex {
// Capture the directory name for the closure to avoid loop variable issues // 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() {
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)
@@ -889,6 +917,43 @@ func makeFilePicker() *tview.Flex {
} }
// Initialize the file list // Initialize the file list
refreshList(startDir) refreshList(startDir)
// Update image preview when selection changes
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)
go func() {
file, err := os.Open(filePath)
if err != nil {
app.QueueUpdate(func() { imgPreview.SetImage(nil) })
return
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
app.QueueUpdate(func() { imgPreview.SetImage(nil) })
return
}
app.QueueUpdate(func() { 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 {
switch event.Key() { switch event.Key() {
@@ -896,6 +961,9 @@ func makeFilePicker() *tview.Flex {
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]
@@ -945,6 +1013,9 @@ func makeFilePicker() *tview.Flex {
} }
// Navigate to the selected directory // Navigate to the selected directory
logger.Info("going to the dir", "dir", targetDir) logger.Info("going to the dir", "dir", targetDir)
if cfg.ImagePreview && imgPreview != nil {
imgPreview.SetImage(nil)
}
refreshList(targetDir) refreshList(targetDir)
dirStack = append(dirStack, targetDir) dirStack = append(dirStack, targetDir)
currentStackPos = len(dirStack) - 1 currentStackPos = len(dirStack) - 1

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)

16
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,12 +1126,8 @@ 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) TTSDoneChan <- true
if cfg.TTS_ENABLED {
// audioStream.TextChan <- chunk
TTSDoneChan <- true
}
} }
if event.Key() == tcell.KeyCtrlW { if event.Key() == tcell.KeyCtrlW {
// INFO: continue bot/text message // INFO: continue bot/text message