summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md8
-rw-r--r--bot.go65
-rw-r--r--models/card.go13
-rw-r--r--pngmeta/metareader.go13
-rw-r--r--pngmeta/partsreader.go2
-rw-r--r--pngmeta/partswriter.go116
-rw-r--r--tables.go23
-rw-r--r--tools.go1
-rw-r--r--tui.go24
9 files changed, 222 insertions, 43 deletions
diff --git a/README.md b/README.md
index d8fecfa..769fb7c 100644
--- a/README.md
+++ b/README.md
@@ -37,12 +37,12 @@
- connection to a model status;
- ===== /llamacpp specific (it has a different body -> interface instead of global var)
- edit syscards / create new ones;
-- consider adding use /completion of llamacpp, since openai endpoint clearly has template|format issues;
+- consider adding use /completion of llamacpp, since openai endpoint clearly has template|format issues; +
- change temp, min-p and other params from tui;
-- DRY;
+- DRY; +
- keybind to switch between openai and llamacpp endpoints;
- option to remove <thinking> from chat history;
-- in chat management table add preview of the last message;
+- in chat management table add preview of the last message; +
### FIX:
- bot responding (or hanging) blocks everything; +
@@ -67,3 +67,5 @@
- F1 can load any chat, by loading chat of other agent it does not switch agents, if that chat is continued, it will rewrite agent in db; (either allow only chats from current agent OR switch agent on chat loading); +
- after chat is deleted: load undeleted chat; +
- name split for llamacpp completion. user msg should end with 'bot_name:';
+- add retry on failed call (and EOF);
+- model info shold be an event and show disconnect status when fails;
diff --git a/bot.go b/bot.go
index 8c98433..753b112 100644
--- a/bot.go
+++ b/bot.go
@@ -13,6 +13,7 @@ import (
"net/http"
"os"
"path"
+ "regexp"
"strings"
"time"
@@ -43,32 +44,6 @@ var (
}
)
-// ====
-
-// DEPRECATED
-// func formMsg(chatBody *models.ChatBody, newMsg, role string) io.Reader {
-// if newMsg != "" { // otherwise let the bot continue
-// newMsg := models.RoleMsg{Role: role, Content: newMsg}
-// chatBody.Messages = append(chatBody.Messages, newMsg)
-// // if rag
-// if cfg.RAGEnabled {
-// ragResp, err := chatRagUse(newMsg.Content)
-// if err != nil {
-// logger.Error("failed to form a rag msg", "error", err)
-// return nil
-// }
-// ragMsg := models.RoleMsg{Role: cfg.ToolRole, Content: ragResp}
-// chatBody.Messages = append(chatBody.Messages, ragMsg)
-// }
-// }
-// data, err := json.Marshal(chatBody)
-// if err != nil {
-// logger.Error("failed to form a msg", "error", err)
-// return nil
-// }
-// return bytes.NewReader(data)
-// }
-
func fetchModelName() {
api := "http://localhost:8080/v1/models"
resp, err := httpClient.Get(api)
@@ -293,6 +268,42 @@ func chatToText(showSys bool) string {
return strings.Join(s, "")
}
+// func removeThinking() {
+// s := chatToTextSlice(false) // will delete tools messages though
+// chat := strings.Join(s, "")
+// chat = thinkRE.ReplaceAllString(chat, "")
+// reS := fmt.Sprintf("[%s:\n,%s:\n]", cfg.AssistantRole, cfg.UserRole)
+// // no way to know what agent wrote which msg
+// s = regexp.MustCompile(reS).Split(chat, -1)
+// }
+
+func textToMsgs(text string) []models.RoleMsg {
+ lines := strings.Split(text, "\n")
+ roleRE := regexp.MustCompile(`^\(\d+\) <.*>:`)
+ resp := []models.RoleMsg{}
+ oldrole := ""
+ for _, line := range lines {
+ if roleRE.MatchString(line) {
+ // extract role
+ role := ""
+ // if role changes
+ if role != oldrole {
+ oldrole = role
+ // newmsg
+ msg := models.RoleMsg{
+ Role: role,
+ }
+ resp = append(resp, msg)
+ }
+ resp[len(resp)-1].Content += "\n" + line
+ }
+ }
+ if len(resp) != 0 {
+ resp[0].Content = strings.TrimPrefix(resp[0].Content, "\n")
+ }
+ return resp
+}
+
func applyCharCard(cc *models.CharCard) {
cfg.AssistantRole = cc.Role
// TODO: need map role->icon
@@ -381,6 +392,6 @@ func init() {
Messages: lastChat,
}
initChunkParser()
- go runModelNameTicker(time.Second * 120)
+ // go runModelNameTicker(time.Second * 120)
// tempLoad()
}
diff --git a/models/card.go b/models/card.go
index fb807f3..adfb030 100644
--- a/models/card.go
+++ b/models/card.go
@@ -20,6 +20,7 @@ type CharCardSpec struct {
Spec string `json:"spec"`
SpecVersion string `json:"spec_version"`
Tags []any `json:"tags"`
+ Extentions []byte `json:"extentions"`
}
type Spec2Wrapper struct {
@@ -43,3 +44,15 @@ type CharCard struct {
Role string `json:"role"`
FilePath string `json:"filepath"`
}
+
+func (cc *CharCard) ToSpec(userName string) *CharCardSpec {
+ descr := strings.ReplaceAll(strings.ReplaceAll(cc.SysPrompt, cc.Role, "{{char}}"), userName, "{{user}}")
+ return &CharCardSpec{
+ Name: cc.Role,
+ Description: descr,
+ FirstMes: cc.FirstMsg,
+ Spec: "chara_card_v2",
+ SpecVersion: "2.0",
+ Extentions: []byte("{}"),
+ }
+}
diff --git a/pngmeta/metareader.go b/pngmeta/metareader.go
index f8ea160..0ebedaa 100644
--- a/pngmeta/metareader.go
+++ b/pngmeta/metareader.go
@@ -8,15 +8,22 @@ import (
"errors"
"fmt"
"io"
+ "log/slog"
"os"
"path"
"strings"
)
const (
- embType = "tEXt"
+ embType = "tEXt"
+ cKey = "chara"
+ IEND = "IEND"
+ header = "\x89PNG\r\n\x1a\n"
+ writeHeader = "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"
)
+var tEXtChunkDataSpecification = "%s\x00%s"
+
type PngEmbed struct {
Key string
Value string
@@ -107,7 +114,7 @@ func readCardJson(fname string) (*models.CharCard, error) {
return &card, nil
}
-func ReadDirCards(dirname, uname string) ([]*models.CharCard, error) {
+func ReadDirCards(dirname, uname string, log *slog.Logger) ([]*models.CharCard, error) {
files, err := os.ReadDir(dirname)
if err != nil {
return nil, err
@@ -121,7 +128,7 @@ func ReadDirCards(dirname, uname string) ([]*models.CharCard, error) {
fpath := path.Join(dirname, f.Name())
cc, err := ReadCard(fpath, uname)
if err != nil {
- // logger.Warn("failed to load card", "error", err)
+ log.Warn("failed to load card", "error", err)
continue
// return nil, err // better to log and continue
}
diff --git a/pngmeta/partsreader.go b/pngmeta/partsreader.go
index b69e4c3..d345a16 100644
--- a/pngmeta/partsreader.go
+++ b/pngmeta/partsreader.go
@@ -14,8 +14,6 @@ var (
ErrBadLength = errors.New("bad length")
)
-const header = "\x89PNG\r\n\x1a\n"
-
type PngChunk struct {
typ string
length int32
diff --git a/pngmeta/partswriter.go b/pngmeta/partswriter.go
new file mode 100644
index 0000000..7c36daf
--- /dev/null
+++ b/pngmeta/partswriter.go
@@ -0,0 +1,116 @@
+package pngmeta
+
+import (
+ "bytes"
+ "elefant/models"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "hash/crc32"
+ "io"
+ "os"
+)
+
+type Writer struct {
+ w io.Writer
+}
+
+func NewPNGWriter(w io.Writer) (*Writer, error) {
+ if _, err := io.WriteString(w, writeHeader); err != nil {
+ return nil, err
+ }
+ return &Writer{w}, nil
+}
+
+func (w *Writer) WriteChunk(length int32, typ string, r io.Reader) error {
+ if err := binary.Write(w.w, binary.BigEndian, length); err != nil {
+ return err
+ }
+ if _, err := w.w.Write([]byte(typ)); err != nil {
+ return err
+ }
+ checksummer := crc32.NewIEEE()
+ checksummer.Write([]byte(typ))
+ if _, err := io.CopyN(io.MultiWriter(w.w, checksummer), r, int64(length)); err != nil {
+ return err
+ }
+ if err := binary.Write(w.w, binary.BigEndian, checksummer.Sum32()); err != nil {
+ return err
+ }
+ return nil
+}
+
+func WriteToPng(c *models.CharCardSpec, fpath, outfile string) error {
+ data, err := os.ReadFile(fpath)
+ if err != nil {
+ return err
+ }
+ jsonData, err := json.Marshal(c)
+ if err != nil {
+ return err
+ }
+ // Base64 encode the JSON data
+ base64Data := base64.StdEncoding.EncodeToString(jsonData)
+ pe := PngEmbed{
+ Key: cKey,
+ Value: base64Data,
+ }
+ w, err := WritetEXtToPngBytes(data, pe)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(outfile, w.Bytes(), 0666)
+}
+
+func WritetEXtToPngBytes(inputBytes []byte, pe PngEmbed) (outputBytes bytes.Buffer, err error) {
+ if !(string(inputBytes[:8]) == header) {
+ return outputBytes, errors.New("wrong file format")
+ }
+ reader := bytes.NewReader(inputBytes)
+ pngr, err := NewPNGStepReader(reader)
+ if err != nil {
+ return outputBytes, fmt.Errorf("NewReader(): %s", err)
+ }
+ pngw, err := NewPNGWriter(&outputBytes)
+ if err != nil {
+ return outputBytes, fmt.Errorf("NewWriter(): %s", err)
+ }
+ for {
+ chunk, err := pngr.Next()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ return outputBytes, fmt.Errorf("NextChunk(): %s", err)
+ }
+ if chunk.Type() != embType {
+ // IENDChunkType will only appear on the final iteration of a valid PNG
+ if chunk.Type() == IEND {
+ // This is where we inject tEXtChunkType as the penultimate chunk with the new value
+ newtEXtChunk := []byte(fmt.Sprintf(tEXtChunkDataSpecification, pe.Key, pe.Value))
+ if err := pngw.WriteChunk(int32(len(newtEXtChunk)), embType, bytes.NewBuffer(newtEXtChunk)); err != nil {
+ return outputBytes, fmt.Errorf("WriteChunk(): %s", err)
+ }
+ // Now we end the buffer with IENDChunkType chunk
+ if err := pngw.WriteChunk(chunk.length, chunk.Type(), chunk); err != nil {
+ return outputBytes, fmt.Errorf("WriteChunk(): %s", err)
+ }
+ } else {
+ // writes back original chunk to buffer
+ if err := pngw.WriteChunk(chunk.length, chunk.Type(), chunk); err != nil {
+ return outputBytes, fmt.Errorf("WriteChunk(): %s", err)
+ }
+ }
+ } else {
+ if _, err := io.Copy(io.Discard, chunk); err != nil {
+ return outputBytes, fmt.Errorf("io.Copy(io.Discard, chunk): %s", err)
+ }
+ }
+ if err := chunk.Close(); err != nil {
+ return outputBytes, fmt.Errorf("chunk.Close(): %s", err)
+ }
+ }
+ return outputBytes, nil
+}
diff --git a/tables.go b/tables.go
index e12a690..0b3ef01 100644
--- a/tables.go
+++ b/tables.go
@@ -4,9 +4,11 @@ import (
"fmt"
"os"
"path"
+ "strings"
"time"
"elefant/models"
+ "elefant/pngmeta"
"elefant/rag"
"github.com/gdamore/tcell/v2"
@@ -14,7 +16,7 @@ import (
)
func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
- actions := []string{"load", "rename", "delete"}
+ actions := []string{"load", "rename", "delete", "update card"}
chatList := make([]string, len(chatMap))
i := 0
for name := range chatMap {
@@ -62,6 +64,7 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
tc.SetTextColor(tcell.ColorRed)
chatActTable.SetSelectable(false, false)
selectedChat := chatList[row]
+ defer pages.RemovePage(historyPage)
// notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text)
switch tc.Text {
case "load":
@@ -98,8 +101,24 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
textView.SetText(chatToText(cfg.ShowSys))
pages.RemovePage(historyPage)
return
+ case "update card":
+ // save updated card
+ fi := strings.Index(selectedChat, "_")
+ agentName := selectedChat[fi+1:]
+ cc, ok := sysMap[agentName]
+ if !ok {
+ logger.Warn("no such card", "agent", agentName)
+ //no:lint
+ notifyUser("error", "no such card: "+agentName)
+ }
+ if err := pngmeta.WriteToPng(cc.ToSpec(cfg.UserRole), cc.FilePath, cc.FilePath); err != nil {
+ logger.Error("failed to write charcard",
+ "error", err)
+ }
+ // pages.RemovePage(historyPage)
+ return
default:
- pages.RemovePage(historyPage)
+ // pages.RemovePage(historyPage)
return
}
})
diff --git a/tools.go b/tools.go
index 0c63976..8fda0ab 100644
--- a/tools.go
+++ b/tools.go
@@ -12,6 +12,7 @@ var (
toolCallRE = regexp.MustCompile(`__tool_call__\s*([\s\S]*?)__tool_call__`)
quotesRE = regexp.MustCompile(`(".*?")`)
starRE = regexp.MustCompile(`(\*.*?\*)`)
+ thinkRE = regexp.MustCompile(`(<think>.*?</think>)`)
// codeBlokRE = regexp.MustCompile(`(\x60\x60\x60.*?\x60\x60\x60)`)
basicSysMsg = `Large Language Model that helps user with any of his requests.`
toolSysMsg = `You're a helpful assistant.
diff --git a/tui.go b/tui.go
index 7645a4f..6766cd5 100644
--- a/tui.go
+++ b/tui.go
@@ -77,11 +77,13 @@ Press Enter to go back
func colorText() {
// INFO: is there a better way to markdown?
- tv := textView.GetText(false)
- cq := quotesRE.ReplaceAllString(tv, `[orange:-:-]$1[-:-:-]`)
+ text := textView.GetText(false)
+ text = quotesRE.ReplaceAllString(text, `[orange:-:-]$1[-:-:-]`)
+ text = starRE.ReplaceAllString(text, `[turquoise::i]$1[-:-:-]`)
+ text = thinkRE.ReplaceAllString(text, `[turquoise::i]$1[-:-:-]`)
// cb := codeBlockColor(cq)
// cb := codeBlockRE.ReplaceAllString(cq, `[blue:black:i]$1[-:-:-]`)
- textView.SetText(starRE.ReplaceAllString(cq, `[turquoise::i]$1[-:-:-]`))
+ textView.SetText(text)
}
func updateStatusLine() {
@@ -91,7 +93,7 @@ func updateStatusLine() {
func initSysCards() ([]string, error) {
labels := []string{}
labels = append(labels, sysLabels...)
- cards, err := pngmeta.ReadDirCards(cfg.SysDir, cfg.UserRole)
+ cards, err := pngmeta.ReadDirCards(cfg.SysDir, cfg.UserRole, logger)
if err != nil {
logger.Error("failed to read sys dir", "error", err)
return nil, err
@@ -116,7 +118,7 @@ func startNewChat() {
logger.Warn("no such sys msg", "name", cfg.AssistantRole)
}
// set chat body
- chatBody.Messages = defaultStarter
+ chatBody.Messages = chatBody.Messages[:2]
textView.SetText(chatToText(cfg.ShowSys))
newChat := &models.Chat{
ID: id + 1,
@@ -186,7 +188,17 @@ func init() {
SetChangedFunc(func() {
app.Draw()
})
- textView.SetBorder(true).SetTitle("chat")
+ // textView.SetBorder(true).SetTitle("chat")
+ textView.SetDoneFunc(func(key tcell.Key) {
+ currentSelection := textView.GetHighlights()
+ if key == tcell.KeyEnter {
+ if len(currentSelection) > 0 {
+ textView.Highlight()
+ } else {
+ textView.Highlight("0").ScrollToHighlight()
+ }
+ }
+ })
focusSwitcher[textArea] = textView
focusSwitcher[textView] = textArea
position = tview.NewTextView().