Compare commits
10 Commits
feat/char-
...
c83779b479
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||

|

|
||||||
|
|||||||
28
bot.go
28
bot.go
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
|||||||
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.
|
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
|
### 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
66
docs/filepicker-search.md
Normal 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.
|
||||||
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() {
|
func colorText() {
|
||||||
text := textView.GetText(false)
|
text := textView.GetText(false)
|
||||||
quoteReplacer := strings.NewReplacer(
|
quoteReplacer := strings.NewReplacer(
|
||||||
|
|||||||
10
main.go
10
main.go
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
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",
|
"role": "Alice",
|
||||||
"filepath": "sysprompts/alice_bob_carl.json",
|
"filepath": "sysprompts/alice_bob_carl.json",
|
||||||
"chars": ["Alice", "Bob", "Carl"],
|
"chars": ["Alice", "Bob", "Carl"],
|
||||||
|
|||||||
75
tables.go
75
tables.go
@@ -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
|
||||||
|
|||||||
1
tools.go
1
tools.go
@@ -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
16
tui.go
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user