diff options
-rw-r--r-- | README.md | 8 | ||||
-rw-r--r-- | bot.go | 65 | ||||
-rw-r--r-- | models/card.go | 13 | ||||
-rw-r--r-- | pngmeta/metareader.go | 13 | ||||
-rw-r--r-- | pngmeta/partsreader.go | 2 | ||||
-rw-r--r-- | pngmeta/partswriter.go | 116 | ||||
-rw-r--r-- | tables.go | 23 | ||||
-rw-r--r-- | tools.go | 1 | ||||
-rw-r--r-- | tui.go | 24 |
9 files changed, 222 insertions, 43 deletions
@@ -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; @@ -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 +} @@ -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 } }) @@ -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. @@ -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(). |