Feat: add character card support

This commit is contained in:
Grail Finder
2024-12-02 19:58:03 +03:00
parent 8d3997baff
commit a5ab816c94
15 changed files with 346 additions and 37 deletions

View File

@@ -18,12 +18,16 @@
- sqlite for the bot memory; +
- rename current chat; +
- help page with all key bindings; +
- change temp, min-p and other params from tui;
- default config file (api url, path to sysprompts, path to log, limits, etc); +
- change temp, min-p and other params from tui;
- fullscreen textarea option (bothersome to implement);
- consider adding use /completion of llamacpp, since openai endpoint clearly has template|format issues;
- export whole chat into a json file;
- directoty with sys prompts;
- directoty with sys prompts (charcards png & json);
- separate messages that are stored and chat and send to the bot, i.e. option to omit tool calls (there might be a point where they are no longer needed in ctx);
- colourschemes, colours or markdown of quotes and styles;
- RAG support|implementation;
- change card-chat pair with one binding;
### FIX:
- bot responding (or haninging) blocks everything; +
@@ -38,3 +42,4 @@
- lets say we have two (or more) agents with the same name across multiple chats. These agents go and ask db for topics they memoriesed. Now they can access topics that aren't meant for them. (so memory should have an option: shareble; that indicates if that memory can be shared across chats);
- if option to show sys msg enabled: it show display new tool responses;
- when bot generation ended with err: need a way to switch back to the bot_resp_false mode;
- no selection focus on modal sys buttons after opening it a second time;

31
bot.go
View File

@@ -28,7 +28,7 @@ var (
chatBody *models.ChatBody
store storage.FullRepo
defaultFirstMsg = "Hello! What can I do for you?"
defaultStarter = []models.MessagesStory{}
defaultStarter = []models.RoleMsg{}
defaultStarterBytes = []byte{}
interruptResp = false
)
@@ -37,7 +37,7 @@ var (
func formMsg(chatBody *models.ChatBody, newMsg, role string) io.Reader {
if newMsg != "" { // otherwise let the bot continue
newMsg := models.MessagesStory{Role: role, Content: newMsg}
newMsg := models.RoleMsg{Role: role, Content: newMsg}
chatBody.Messages = append(chatBody.Messages, newMsg)
}
data, err := json.Marshal(chatBody)
@@ -128,7 +128,7 @@ out:
}
}
botRespMode = false
chatBody.Messages = append(chatBody.Messages, models.MessagesStory{
chatBody.Messages = append(chatBody.Messages, models.RoleMsg{
Role: cfg.AssistantRole, Content: respText.String(),
})
// bot msg is done;
@@ -182,8 +182,17 @@ func chatToText(showSys bool) string {
return strings.Join(s, "")
}
// func textToMsg(rawMsg string) models.MessagesStory {
// msg := models.MessagesStory{}
func applyCharCard(cc *models.CharCard) {
cfg.AssistantRole = cc.Role
newChat := []models.RoleMsg{
{Role: "system", Content: cc.SysPrompt},
{Role: cfg.AssistantRole, Content: cc.FirstMsg},
}
chatBody.Messages = newChat
}
// func textToMsg(rawMsg string) models.RoleMsg {
// msg := models.RoleMsg{}
// // system and tool?
// if strings.HasPrefix(rawMsg, cfg.AssistantIcon) {
// msg.Role = cfg.AssistantRole
@@ -198,8 +207,8 @@ func chatToText(showSys bool) string {
// return msg
// }
// func textSliceToChat(chat []string) []models.MessagesStory {
// resp := make([]models.MessagesStory, len(chat))
// func textSliceToChat(chat []string) []models.RoleMsg {
// resp := make([]models.RoleMsg, len(chat))
// for i, rawMsg := range chat {
// msg := textToMsg(rawMsg)
// resp[i] = msg
@@ -209,8 +218,8 @@ func chatToText(showSys bool) string {
func init() {
cfg = config.LoadConfigOrDefault("config.example.toml")
defaultStarter = []models.MessagesStory{
{Role: "system", Content: systemMsg},
defaultStarter = []models.RoleMsg{
{Role: "system", Content: basicSysMsg},
{Role: cfg.AssistantRole, Content: defaultFirstMsg},
}
file, err := os.OpenFile(cfg.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
@@ -223,6 +232,10 @@ func init() {
logger.Error("failed to marshal defaultStarter", "error", err)
return
}
// load cards
basicCard.Role = cfg.AssistantRole
toolCard.Role = cfg.AssistantRole
//
logger = slog.New(slog.NewTextHandler(file, nil))
store = storage.NewProviderSQL("test.db", logger)
// https://github.com/coreydaley/ggerganov-llama.cpp/blob/master/examples/server/README.md

View File

@@ -7,3 +7,4 @@ AssistantRole = "assistant"
AssistantIcon = "<🤖>: "
UserIcon = "<user>: "
ToolIcon = "<>>: "
SysDir = "sysprompts"

View File

@@ -17,6 +17,7 @@ type Config struct {
UserIcon string `toml:"UserIcon"`
ToolIcon string `toml:"ToolIcon"`
ChunkLimit uint32 `toml:"ChunkLimit"`
SysDir string `toml:"SysDir"`
}
func LoadConfigOrDefault(fn string) *Config {
@@ -34,6 +35,7 @@ func LoadConfigOrDefault(fn string) *Config {
config.ToolRole = "tool"
config.AssistantRole = "assistant"
config.ChunkLimit = 8192
config.SysDir = "sysprompts"
}
return config
}

41
models/card.go Normal file
View File

@@ -0,0 +1,41 @@
package models
import "strings"
// https://github.com/malfoyslastname/character-card-spec-v2/blob/main/spec_v2.md
// what a bloat; trim to Role->Msg pair and first msg
type CharCardSpec struct {
Name string `json:"name"`
Description string `json:"description"`
Personality string `json:"personality"`
FirstMes string `json:"first_mes"`
Avatar string `json:"avatar"`
Chat string `json:"chat"`
MesExample string `json:"mes_example"`
Scenario string `json:"scenario"`
CreateDate string `json:"create_date"`
Talkativeness string `json:"talkativeness"`
Fav bool `json:"fav"`
Creatorcomment string `json:"creatorcomment"`
Spec string `json:"spec"`
SpecVersion string `json:"spec_version"`
Tags []any `json:"tags"`
}
func (c *CharCardSpec) Simplify(userName, fpath string) *CharCard {
fm := strings.ReplaceAll(strings.ReplaceAll(c.FirstMes, "{{char}}", c.Name), "{{user}}", userName)
sysPr := strings.ReplaceAll(strings.ReplaceAll(c.Description, "{{char}}", c.Name), "{{user}}", userName)
return &CharCard{
SysPrompt: sysPr,
FirstMsg: fm,
Role: c.Name,
FilePath: fpath,
}
}
type CharCard struct {
SysPrompt string
FirstMsg string
Role string
FilePath string
}

View File

@@ -8,13 +8,13 @@ import (
type Chat struct {
ID uint32 `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Msgs string `db:"msgs" json:"msgs"` // []MessagesStory to string json
Msgs string `db:"msgs" json:"msgs"` // []RoleMsg to string json
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (c Chat) ToHistory() ([]MessagesStory, error) {
resp := []MessagesStory{}
func (c Chat) ToHistory() ([]RoleMsg, error) {
resp := []RoleMsg{}
if err := json.Unmarshal([]byte(c.Msgs), &resp); err != nil {
return nil, err
}

View File

@@ -5,12 +5,6 @@ import (
"strings"
)
// type FuncCall struct {
// XMLName xml.Name `xml:"tool_call"`
// Name string `xml:"name"`
// Args []string `xml:"args"`
// }
type FuncCall struct {
Name string `json:"name"`
Args []string `json:"args"`
@@ -56,12 +50,12 @@ type LLMRespChunk struct {
} `json:"usage"`
}
type MessagesStory struct {
type RoleMsg struct {
Role string `json:"role"`
Content string `json:"content"`
}
func (m MessagesStory) ToText(i int) string {
func (m RoleMsg) ToText(i int) string {
icon := ""
switch m.Role {
case "assistant":
@@ -72,20 +66,22 @@ func (m MessagesStory) ToText(i int) string {
icon = fmt.Sprintf("(%d) <system>: ", i)
case "tool":
icon = fmt.Sprintf("(%d) <tool>: ", i)
default:
icon = fmt.Sprintf("(%d) <%s>: ", i, m.Role)
}
textMsg := fmt.Sprintf("%s%s\n", icon, m.Content)
return strings.ReplaceAll(textMsg, "\n\n", "\n")
}
type ChatBody struct {
Model string `json:"model"`
Stream bool `json:"stream"`
Messages []MessagesStory `json:"messages"`
Model string `json:"model"`
Stream bool `json:"stream"`
Messages []RoleMsg `json:"messages"`
}
type ChatToolsBody struct {
Model string `json:"model"`
Messages []MessagesStory `json:"messages"`
Model string `json:"model"`
Messages []RoleMsg `json:"messages"`
Tools []struct {
Type string `json:"type"`
Function struct {

107
pngmeta/metareader.go Normal file
View File

@@ -0,0 +1,107 @@
package pngmeta
import (
"bytes"
"elefant/models"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"path"
"strings"
)
const (
embType = "tEXt"
)
type PngEmbed struct {
Key string
Value string
}
func (c PngEmbed) GetDecodedValue() (*models.CharCardSpec, error) {
data, err := base64.StdEncoding.DecodeString(c.Value)
if err != nil {
return nil, err
}
card := &models.CharCardSpec{}
if err := json.Unmarshal(data, &card); err != nil {
return nil, err
}
return card, nil
}
func extractChar(fname string) (*PngEmbed, error) {
data, err := os.ReadFile(fname)
if err != nil {
return nil, err
}
reader := bytes.NewReader(data)
pr, err := NewPNGStepReader(reader)
if err != nil {
return nil, err
}
for {
step, err := pr.Next()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
}
if step.Type() != embType {
if _, err := io.Copy(io.Discard, step); err != nil {
return nil, err
}
} else {
buf, err := io.ReadAll(step)
if err != nil {
return nil, err
}
dataInstep := string(buf)
values := strings.Split(dataInstep, "\x00")
if len(values) == 2 {
return &PngEmbed{Key: values[0], Value: values[1]}, nil
}
}
if err := step.Close(); err != nil {
return nil, err
}
}
return nil, errors.New("failed to find embedded char in png: " + fname)
}
func ReadCard(fname, uname string) (*models.CharCard, error) {
pe, err := extractChar(fname)
if err != nil {
return nil, err
}
charSpec, err := pe.GetDecodedValue()
if err != nil {
return nil, err
}
return charSpec.Simplify(uname, fname), nil
}
func ReadDirCards(dirname, uname string) ([]*models.CharCard, error) {
files, err := os.ReadDir(dirname)
if err != nil {
return nil, err
}
resp := []*models.CharCard{}
for _, f := range files {
if !strings.HasSuffix(f.Name(), ".png") {
continue
}
fpath := path.Join(dirname, f.Name())
cc, err := ReadCard(fpath, uname)
if err != nil {
// log err
return nil, err
// continue
}
resp = append(resp, cc)
}
return resp, nil
}

View File

@@ -0,0 +1,33 @@
package pngmeta
import (
"fmt"
"testing"
)
func TestReadMeta(t *testing.T) {
cases := []struct {
Filename string
}{
{
Filename: "../sysprompts/default_Seraphina.png",
},
{
Filename: "../sysprompts/llama.png",
},
}
for i, tc := range cases {
t.Run(fmt.Sprintf("test_%d", i), func(t *testing.T) {
// Call the readMeta function
pembed, err := extractChar(tc.Filename)
if err != nil {
t.Errorf("Expected no error, but got %v", err)
}
v, err := pembed.GetDecodedValue()
if err != nil {
t.Errorf("Expected no error, but got %v\n", err)
}
fmt.Printf("%+v\n", v.Simplify("Adam"))
})
}
}

77
pngmeta/partsreader.go Normal file
View File

@@ -0,0 +1,77 @@
package pngmeta
import (
"encoding/binary"
"errors"
"hash"
"hash/crc32"
"io"
)
var (
ErrCRC32Mismatch = errors.New("crc32 mismatch")
ErrNotPNG = errors.New("not png")
ErrBadLength = errors.New("bad length")
)
const header = "\x89PNG\r\n\x1a\n"
type PngChunk struct {
typ string
length int32
r io.Reader
realR io.Reader
checksummer hash.Hash32
}
func (c *PngChunk) Read(p []byte) (int, error) {
return io.TeeReader(c.r, c.checksummer).Read(p)
}
func (c *PngChunk) Close() error {
var crc32 uint32
if err := binary.Read(c.realR, binary.BigEndian, &crc32); err != nil {
return err
}
if crc32 != c.checksummer.Sum32() {
return ErrCRC32Mismatch
}
return nil
}
func (c *PngChunk) Type() string {
return c.typ
}
type Reader struct {
r io.Reader
}
func NewPNGStepReader(r io.Reader) (*Reader, error) {
expectedHeader := make([]byte, len(header))
if _, err := io.ReadFull(r, expectedHeader); err != nil {
return nil, err
}
if string(expectedHeader) != header {
return nil, ErrNotPNG
}
return &Reader{r}, nil
}
func (r *Reader) Next() (*PngChunk, error) {
var length int32
if err := binary.Read(r.r, binary.BigEndian, &length); err != nil {
return nil, err
}
if length < 0 {
return nil, ErrBadLength
}
var rawTyp [4]byte
if _, err := io.ReadFull(r.r, rawTyp[:]); err != nil {
return nil, err
}
typ := string(rawTyp[:])
checksummer := crc32.NewIEEE()
checksummer.Write([]byte(typ))
return &PngChunk{typ, length, io.LimitReader(r.r, int64(length)), r.r, checksummer}, nil
}

View File

@@ -14,7 +14,7 @@ var (
chatMap = make(map[string]*models.Chat)
)
func historyToSJSON(msgs []models.MessagesStory) (string, error) {
func historyToSJSON(msgs []models.RoleMsg) (string, error) {
data, err := json.Marshal(msgs)
if err != nil {
return "", err
@@ -25,7 +25,7 @@ func historyToSJSON(msgs []models.MessagesStory) (string, error) {
return string(data), nil
}
func updateStorageChat(name string, msgs []models.MessagesStory) error {
func updateStorageChat(name string, msgs []models.RoleMsg) error {
var err error
chat, ok := chatMap[name]
if !ok {
@@ -59,7 +59,7 @@ func loadHistoryChats() ([]string, error) {
return resp, nil
}
func loadHistoryChat(chatName string) ([]models.MessagesStory, error) {
func loadHistoryChat(chatName string) ([]models.RoleMsg, error) {
chat, ok := chatMap[chatName]
if !ok {
err := errors.New("failed to read chat")
@@ -70,7 +70,7 @@ func loadHistoryChat(chatName string) ([]models.MessagesStory, error) {
return chat.ToHistory()
}
func loadOldChatOrGetNew() []models.MessagesStory {
func loadOldChatOrGetNew() []models.RoleMsg {
newChat := &models.Chat{
ID: 0,
CreatedAt: time.Now(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

BIN
sysprompts/llama.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

View File

@@ -10,8 +10,8 @@ import (
var (
// TODO: form that message based on existing funcs
basicSysMsg = `Large Language Model that helps user with any of his requests.`
toolCallRE = regexp.MustCompile(`__tool_call__\s*([\s\S]*?)__tool_call__`)
basicSysMsg = `Large Language Model that helps user with any of his requests.`
toolSysMsg = `You're a helpful assistant.
# Tools
You can do functions call if needed.
@@ -47,8 +47,20 @@ Tool call is addressed to the tool agent, avoid sending more info than tool call
When done right, tool call will be delivered to the tool agent. tool agent will respond with the results of the call.
After that you are free to respond to the user.
`
systemMsg = toolSysMsg
sysMap = map[string]string{"basic_sys": basicSysMsg, "tool_sys": toolSysMsg}
basicCard = &models.CharCard{
SysPrompt: basicSysMsg,
FirstMsg: defaultFirstMsg,
Role: "",
FilePath: "",
}
toolCard = &models.CharCard{
SysPrompt: toolSysMsg,
FirstMsg: defaultFirstMsg,
Role: "",
FilePath: "",
}
// sysMap = map[string]string{"basic_sys": basicSysMsg, "tool_sys": toolSysMsg}
sysMap = map[string]*models.CharCard{"basic_sys": basicCard, "tool_sys": toolCard}
sysLabels = []string{"cancel", "basic_sys", "tool_sys"}
)

28
tui.go
View File

@@ -2,6 +2,7 @@ package main
import (
"elefant/models"
"elefant/pngmeta"
"fmt"
"strconv"
"time"
@@ -119,22 +120,27 @@ func init() {
})
sysModal = tview.NewModal().
SetText("Switch sys msg:").
AddButtons(sysLabels).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
switch buttonLabel {
case "cancel":
pages.RemovePage("sys")
return
default:
sysMsg, ok := sysMap[buttonLabel]
cc, ok := sysMap[buttonLabel]
if !ok {
logger.Warn("no such sys msg", "name", buttonLabel)
pages.RemovePage("sys")
return
}
chatBody.Messages[0].Content = sysMsg
// to replace it old role in text
// oldRole := chatBody.Messages[0].Role
// replace every role with char
// chatBody.Messages[0].Content = cc.SysPrompt
// chatBody.Messages[1].Content = cc.FirstMsg
applyCharCard(cc)
// replace textview
textView.SetText(chatToText(cfg.ShowSys))
sysModal.ClearButtons()
pages.RemovePage("sys")
}
})
@@ -312,6 +318,22 @@ func init() {
}
if event.Key() == tcell.KeyCtrlS {
// switch sys prompt
cards, err := pngmeta.ReadDirCards(cfg.SysDir, cfg.UserRole)
if err != nil {
logger.Error("failed to read sys dir", "error", err)
if err := notifyUser("error", "failed to read: "+cfg.SysDir); err != nil {
logger.Debug("failed to notify user", "error", err)
}
return nil
}
labels := []string{}
labels = append(labels, sysLabels...)
for _, cc := range cards {
labels = append(labels, cc.Role)
sysMap[cc.Role] = cc
}
sysModal.AddButtons(labels)
// load all chars
pages.AddPage("sys", sysModal, true, true)
return nil
}