Feat: edit agent png cards

This commit is contained in:
Grail Finder
2025-01-28 20:40:09 +03:00
parent 976d6423ac
commit 7bf18dede5
9 changed files with 222 additions and 43 deletions

View File

@@ -37,12 +37,12 @@
- connection to a model status; - connection to a model status;
- ===== /llamacpp specific (it has a different body -> interface instead of global var) - ===== /llamacpp specific (it has a different body -> interface instead of global var)
- edit syscards / create new ones; - 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; - change temp, min-p and other params from tui;
- DRY; - DRY; +
- keybind to switch between openai and llamacpp endpoints; - keybind to switch between openai and llamacpp endpoints;
- option to remove <thinking> from chat history; - 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: ### FIX:
- bot responding (or hanging) blocks everything; + - 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); + - 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; + - after chat is deleted: load undeleted chat; +
- name split for llamacpp completion. user msg should end with 'bot_name:'; - 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;

65
bot.go
View File

@@ -13,6 +13,7 @@ import (
"net/http" "net/http"
"os" "os"
"path" "path"
"regexp"
"strings" "strings"
"time" "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() { func fetchModelName() {
api := "http://localhost:8080/v1/models" api := "http://localhost:8080/v1/models"
resp, err := httpClient.Get(api) resp, err := httpClient.Get(api)
@@ -293,6 +268,42 @@ func chatToText(showSys bool) string {
return strings.Join(s, "") 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) { func applyCharCard(cc *models.CharCard) {
cfg.AssistantRole = cc.Role cfg.AssistantRole = cc.Role
// TODO: need map role->icon // TODO: need map role->icon
@@ -381,6 +392,6 @@ func init() {
Messages: lastChat, Messages: lastChat,
} }
initChunkParser() initChunkParser()
go runModelNameTicker(time.Second * 120) // go runModelNameTicker(time.Second * 120)
// tempLoad() // tempLoad()
} }

View File

@@ -20,6 +20,7 @@ type CharCardSpec struct {
Spec string `json:"spec"` Spec string `json:"spec"`
SpecVersion string `json:"spec_version"` SpecVersion string `json:"spec_version"`
Tags []any `json:"tags"` Tags []any `json:"tags"`
Extentions []byte `json:"extentions"`
} }
type Spec2Wrapper struct { type Spec2Wrapper struct {
@@ -43,3 +44,15 @@ type CharCard struct {
Role string `json:"role"` Role string `json:"role"`
FilePath string `json:"filepath"` 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("{}"),
}
}

View File

@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"os" "os"
"path" "path"
"strings" "strings"
@@ -15,8 +16,14 @@ import (
const ( 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 { type PngEmbed struct {
Key string Key string
Value string Value string
@@ -107,7 +114,7 @@ func readCardJson(fname string) (*models.CharCard, error) {
return &card, nil 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) files, err := os.ReadDir(dirname)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -121,7 +128,7 @@ func ReadDirCards(dirname, uname string) ([]*models.CharCard, error) {
fpath := path.Join(dirname, f.Name()) fpath := path.Join(dirname, f.Name())
cc, err := ReadCard(fpath, uname) cc, err := ReadCard(fpath, uname)
if err != nil { if err != nil {
// logger.Warn("failed to load card", "error", err) log.Warn("failed to load card", "error", err)
continue continue
// return nil, err // better to log and continue // return nil, err // better to log and continue
} }

View File

@@ -14,8 +14,6 @@ var (
ErrBadLength = errors.New("bad length") ErrBadLength = errors.New("bad length")
) )
const header = "\x89PNG\r\n\x1a\n"
type PngChunk struct { type PngChunk struct {
typ string typ string
length int32 length int32

116
pngmeta/partswriter.go Normal file
View File

@@ -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
}

View File

@@ -4,9 +4,11 @@ import (
"fmt" "fmt"
"os" "os"
"path" "path"
"strings"
"time" "time"
"elefant/models" "elefant/models"
"elefant/pngmeta"
"elefant/rag" "elefant/rag"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
@@ -14,7 +16,7 @@ import (
) )
func makeChatTable(chatMap map[string]models.Chat) *tview.Table { 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)) chatList := make([]string, len(chatMap))
i := 0 i := 0
for name := range chatMap { for name := range chatMap {
@@ -62,6 +64,7 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
tc.SetTextColor(tcell.ColorRed) tc.SetTextColor(tcell.ColorRed)
chatActTable.SetSelectable(false, false) chatActTable.SetSelectable(false, false)
selectedChat := chatList[row] selectedChat := chatList[row]
defer pages.RemovePage(historyPage)
// notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text) // notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text)
switch tc.Text { switch tc.Text {
case "load": case "load":
@@ -98,8 +101,24 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
textView.SetText(chatToText(cfg.ShowSys)) textView.SetText(chatToText(cfg.ShowSys))
pages.RemovePage(historyPage) pages.RemovePage(historyPage)
return 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: default:
pages.RemovePage(historyPage) // pages.RemovePage(historyPage)
return return
} }
}) })

View File

@@ -12,6 +12,7 @@ var (
toolCallRE = regexp.MustCompile(`__tool_call__\s*([\s\S]*?)__tool_call__`) toolCallRE = regexp.MustCompile(`__tool_call__\s*([\s\S]*?)__tool_call__`)
quotesRE = regexp.MustCompile(`(".*?")`) quotesRE = regexp.MustCompile(`(".*?")`)
starRE = regexp.MustCompile(`(\*.*?\*)`) starRE = regexp.MustCompile(`(\*.*?\*)`)
thinkRE = regexp.MustCompile(`(<think>.*?</think>)`)
// codeBlokRE = regexp.MustCompile(`(\x60\x60\x60.*?\x60\x60\x60)`) // codeBlokRE = regexp.MustCompile(`(\x60\x60\x60.*?\x60\x60\x60)`)
basicSysMsg = `Large Language Model that helps user with any of his requests.` basicSysMsg = `Large Language Model that helps user with any of his requests.`
toolSysMsg = `You're a helpful assistant. toolSysMsg = `You're a helpful assistant.

24
tui.go
View File

@@ -77,11 +77,13 @@ Press Enter to go back
func colorText() { func colorText() {
// INFO: is there a better way to markdown? // INFO: is there a better way to markdown?
tv := textView.GetText(false) text := textView.GetText(false)
cq := quotesRE.ReplaceAllString(tv, `[orange:-:-]$1[-:-:-]`) text = quotesRE.ReplaceAllString(text, `[orange:-:-]$1[-:-:-]`)
text = starRE.ReplaceAllString(text, `[turquoise::i]$1[-:-:-]`)
text = thinkRE.ReplaceAllString(text, `[turquoise::i]$1[-:-:-]`)
// cb := codeBlockColor(cq) // cb := codeBlockColor(cq)
// cb := codeBlockRE.ReplaceAllString(cq, `[blue:black:i]$1[-:-:-]`) // cb := codeBlockRE.ReplaceAllString(cq, `[blue:black:i]$1[-:-:-]`)
textView.SetText(starRE.ReplaceAllString(cq, `[turquoise::i]$1[-:-:-]`)) textView.SetText(text)
} }
func updateStatusLine() { func updateStatusLine() {
@@ -91,7 +93,7 @@ func updateStatusLine() {
func initSysCards() ([]string, error) { func initSysCards() ([]string, error) {
labels := []string{} labels := []string{}
labels = append(labels, sysLabels...) labels = append(labels, sysLabels...)
cards, err := pngmeta.ReadDirCards(cfg.SysDir, cfg.UserRole) cards, err := pngmeta.ReadDirCards(cfg.SysDir, cfg.UserRole, logger)
if err != nil { if err != nil {
logger.Error("failed to read sys dir", "error", err) logger.Error("failed to read sys dir", "error", err)
return nil, err return nil, err
@@ -116,7 +118,7 @@ func startNewChat() {
logger.Warn("no such sys msg", "name", cfg.AssistantRole) logger.Warn("no such sys msg", "name", cfg.AssistantRole)
} }
// set chat body // set chat body
chatBody.Messages = defaultStarter chatBody.Messages = chatBody.Messages[:2]
textView.SetText(chatToText(cfg.ShowSys)) textView.SetText(chatToText(cfg.ShowSys))
newChat := &models.Chat{ newChat := &models.Chat{
ID: id + 1, ID: id + 1,
@@ -186,7 +188,17 @@ func init() {
SetChangedFunc(func() { SetChangedFunc(func() {
app.Draw() 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[textArea] = textView
focusSwitcher[textView] = textArea focusSwitcher[textView] = textArea
position = tview.NewTextView(). position = tview.NewTextView().