summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore7
-rw-r--r--.golangci.yml44
-rw-r--r--Makefile13
-rw-r--r--README.md85
-rw-r--r--assets/ex01.pngbin0 -> 69006 bytes
-rw-r--r--bot.go531
-rw-r--r--config.example.toml21
-rw-r--r--config/config.go94
-rw-r--r--extra/cluedo.go73
-rw-r--r--extra/cluedo_test.go50
-rw-r--r--extra/stt.go166
-rw-r--r--extra/tts.go212
-rw-r--r--extra/twentyq.go11
-rw-r--r--extra/vad.go1
-rw-r--r--go.mod20
-rw-r--r--go.sum44
-rw-r--r--llm.go288
-rw-r--r--main.go225
-rw-r--r--main_test.go41
-rw-r--r--models/card.go58
-rw-r--r--models/db.go17
-rw-r--r--models/extra.go8
-rw-r--r--models/models.go301
-rw-r--r--pngmeta/altwriter.go133
-rw-r--r--pngmeta/metareader.go147
-rw-r--r--pngmeta/metareader_test.go194
-rw-r--r--pngmeta/partsreader.go75
-rw-r--r--pngmeta/partswriter.go112
-rw-r--r--rag/main.go265
-rw-r--r--server.go74
-rw-r--r--session.go99
-rw-r--r--storage/memory.go14
-rw-r--r--storage/migrate.go7
-rw-r--r--storage/migrations/001_init.up.sql1
-rw-r--r--storage/migrations/002_add_vector.up.sql12
-rw-r--r--storage/storage.go54
-rw-r--r--storage/storage_test.go119
-rw-r--r--storage/vector.go163
-rw-r--r--sysprompts/cluedo.json7
-rw-r--r--sysprompts/llama.pngbin0 -> 620775 bytes
-rw-r--r--tables.go535
-rw-r--r--tools.go148
-rw-r--r--tui.go800
-rwxr-xr-xvec0.sobin0 -> 146680 bytes
44 files changed, 4794 insertions, 475 deletions
diff --git a/.gitignore b/.gitignore
index 9c37cd9..6ec208c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,10 @@ testlog
elefant
history/
*.db
+config.toml
+sysprompts/*
+!sysprompts/cluedo.json
+history_bak/
+.aider*
+tags
+gf-lt
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..d377c38
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,44 @@
+version: "2"
+run:
+ concurrency: 2
+ tests: false
+linters:
+ default: none
+ enable:
+ - bodyclose
+ - errcheck
+ - fatcontext
+ - govet
+ - ineffassign
+ - noctx
+ - perfsprint
+ - prealloc
+ - staticcheck
+ - unused
+ settings:
+ funlen:
+ lines: 80
+ statements: 50
+ lll:
+ line-length: 80
+ exclusions:
+ generated: lax
+ presets:
+ - comments
+ - common-false-positives
+ - legacy
+ - std-error-handling
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
+issues:
+ max-issues-per-linter: 0
+ max-same-issues: 0
+formatters:
+ exclusions:
+ generated: lax
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..87304cc
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,13 @@
+.PHONY: setconfig run lint
+
+run: setconfig
+ go build -o gf-lt && ./gf-lt
+
+server: setconfig
+ go build -o gf-lt && ./gf-lt -port 3333
+
+setconfig:
+ find config.toml &>/dev/null || cp config.example.toml config.toml
+
+lint: ## Run linters. Use make install-linters first.
+ golangci-lint run -c .golangci.yml ./...
diff --git a/README.md b/README.md
index f7c63bf..6c29107 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,62 @@
-### TODO:
-- scrolling chat history; (somewhat works out of box); +
-- log errors to file; +
-- give serial id to each msg in chat to track it; (use slice index) +
-- show msg id next to the msg; +
-- regen last message; +
-- delete last message; +
-- edit message? (including from bot); +
-- ability to copy message; +
-- menu with old chats (chat files); +
-- fullscreen textarea option (for long prompt);
-- tab to switch selection between textview and textarea (input and chat); +
-- basic tools: memorize and recall;
-- stop stream from the bot; +
-- sqlitedb instead of chatfiles; +
-- sqlite for the bot memory;
-- option to switch between predefined sys prompts;
+### gf-lt (grail finder's llm tui)
+terminal user interface for large language models.
+made with use of [tview](https://github.com/rivo/tview)
-### FIX:
-- bot responding (or haninging) blocks everything; +
-- programm requires history folder, but it is .gitignore; +
-- at first run chat table does not exist; run migrations sql on startup; +
-- Tab is needed to copy paste text into textarea box, use shift+tab to switch focus; (changed tp pgup) +
-- delete last msg: can have unexpected behavior (deletes what appears to be two messages if last bot msg was not generated (should only delete icon in that case));
-- empty input to continue bot msg gens new msg index and bot icon;
+#### has/supports
+- character card spec;
+- llama.cpp api, deepseek (other ones were not tested);
+- showing images (not really, for now only if your char card is png it could show it);
+- tts/stt (if whisper.cpp server / fastapi tts server are provided);
+
+#### does not have/support
+- images; (ctrl+j will show an image of the card you use, but that is about it);
+- RAG; (RAG was implemented, but I found it unusable and then sql extention broke, so no RAG);
+- MCP; (agentic is implemented, but as a raw and predefined functions for llm to use. see [tools.go](https://github.com/GrailFinder/gf-lt/blob/master/tools.go));
+
+#### usage examples
+![usage example](assets/ex01.png)
+
+#### how to install
+(requires golang)
+clone the project
+```
+cd gf-lt
+make
+```
+
+#### keybindings
+while running you can press f12 for list of keys;
+```
+Esc: send msg
+PgUp/Down: switch focus between input and chat widgets
+F1: manage chats
+F2: regen last
+F3: delete last msg
+F4: edit msg
+F5: toggle system
+F6: interrupt bot resp
+F7: copy last msg to clipboard (linux xclip)
+F8: copy n msg to clipboard (linux xclip)
+F9: table to copy from; with all code blocks
+F11: import chat file
+F12: show this help page
+Ctrl+w: resume generation on the last msg
+Ctrl+s: load new char/agent
+Ctrl+e: export chat to json file
+Ctrl+n: start a new chat
+Ctrl+c: close programm
+Ctrl+p: props edit form (min-p, dry, etc.)
+Ctrl+v: switch between /completion and /chat api (if provided in config)
+Ctrl+r: start/stop recording from your microphone (needs stt server)
+Ctrl+t: remove thinking (<think>) and tool messages from context (delete from chat)
+Ctrl+l: update connected model name (llamacpp)
+Ctrl+k: switch tool use (recommend tool use to llm after user msg)
+Ctrl+j: if chat agent is char.png will show the image; then any key to return
+Ctrl+a: interrupt tts (needs tts server)
+```
+
+#### setting up config
+```
+cp config.example.toml config.toml
+```
+set values as you need them to be.
diff --git a/assets/ex01.png b/assets/ex01.png
new file mode 100644
index 0000000..b0f5ae3
--- /dev/null
+++ b/assets/ex01.png
Binary files differ
diff --git a/bot.go b/bot.go
index 66303a2..0503548 100644
--- a/bot.go
+++ b/bot.go
@@ -3,197 +3,412 @@ package main
import (
"bufio"
"bytes"
- "elefant/models"
- "elefant/storage"
+ "context"
"encoding/json"
"fmt"
+ "gf-lt/config"
+ "gf-lt/extra"
+ "gf-lt/models"
+ "gf-lt/rag"
+ "gf-lt/storage"
"io"
"log/slog"
+ "net"
"net/http"
"os"
+ "path"
+ "strconv"
"strings"
"time"
+ "github.com/neurosnap/sentences/english"
"github.com/rivo/tview"
)
-var httpClient = http.Client{
- Timeout: time.Second * 20,
-}
-
var (
- logger *slog.Logger
- APIURL = "http://localhost:8080/v1/chat/completions"
- DB = map[string]map[string]any{}
- userRole = "user"
- assistantRole = "assistant"
- toolRole = "tool"
- assistantIcon = "<🤖>: "
- userIcon = "<user>: "
- historyDir = "./history/"
- // TODO: pass as an cli arg
- showSystemMsgs bool
- activeChatName string
- chunkChan = make(chan string, 10)
- streamDone = make(chan bool, 1)
- chatBody *models.ChatBody
- store storage.ChatHistory
- defaultFirstMsg = "Hello! What can I do for you?"
- defaultStarter = []models.MessagesStory{
- {Role: "system", Content: systemMsg},
- {Role: assistantRole, Content: defaultFirstMsg},
- }
- interruptResp = false
+ httpClient = &http.Client{}
+ cluedoState *extra.CluedoRoundInfo // Current game state
+ playerOrder []string // Turn order tracking
+ cfg *config.Config
+ logger *slog.Logger
+ logLevel = new(slog.LevelVar)
+ activeChatName string
+ chunkChan = make(chan string, 10)
+ streamDone = make(chan bool, 1)
+ chatBody *models.ChatBody
+ store storage.FullRepo
+ defaultFirstMsg = "Hello! What can I do for you?"
+ defaultStarter = []models.RoleMsg{}
+ defaultStarterBytes = []byte{}
+ interruptResp = false
+ ragger *rag.RAG
+ chunkParser ChunkParser
+ //nolint:unused // TTS_ENABLED conditionally uses this
+ orator extra.Orator
+ asr extra.STT
+ defaultLCPProps = map[string]float32{
+ "temperature": 0.8,
+ "dry_multiplier": 0.0,
+ "min_p": 0.05,
+ "n_predict": -1.0,
+ }
)
-// ====
+func createClient(connectTimeout time.Duration) *http.Client {
+ // Custom transport with connection timeout
+ transport := &http.Transport{
+ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+ // Create a dialer with connection timeout
+ dialer := &net.Dialer{
+ Timeout: connectTimeout,
+ KeepAlive: 30 * time.Second, // Optional
+ }
+ return dialer.DialContext(ctx, network, addr)
+ },
+ // Other transport settings (optional)
+ TLSHandshakeTimeout: connectTimeout,
+ ResponseHeaderTimeout: connectTimeout,
+ }
+ // Client with no overall timeout (or set to streaming-safe duration)
+ return &http.Client{
+ Transport: transport,
+ Timeout: 0, // No overall timeout (for streaming)
+ }
+}
-func getUserInput(userPrompt string) string {
- fmt.Printf(userPrompt)
- reader := bufio.NewReader(os.Stdin)
- line, err := reader.ReadString('\n')
+func fetchModelName() *models.LLMModels {
+ // TODO: to config
+ api := "http://localhost:8080/v1/models"
+ //nolint
+ resp, err := httpClient.Get(api)
if err != nil {
- panic(err) // think about it
+ logger.Warn("failed to get model", "link", api, "error", err)
+ return nil
}
- return line
+ defer resp.Body.Close()
+ llmModel := models.LLMModels{}
+ if err := json.NewDecoder(resp.Body).Decode(&llmModel); err != nil {
+ logger.Warn("failed to decode resp", "link", api, "error", err)
+ return nil
+ }
+ if resp.StatusCode != 200 {
+ chatBody.Model = "disconnected"
+ return nil
+ }
+ chatBody.Model = path.Base(llmModel.Data[0].ID)
+ return &llmModel
}
-func formMsg(chatBody *models.ChatBody, newMsg, role string) io.Reader {
- if newMsg != "" { // otherwise let the bot continue
- newMsg := models.MessagesStory{Role: role, Content: newMsg}
- chatBody.Messages = append(chatBody.Messages, newMsg)
+// nolint
+func fetchDSBalance() *models.DSBalance {
+ url := "https://api.deepseek.com/user/balance"
+ method := "GET"
+ // nolint
+ req, err := http.NewRequest(method, url, nil)
+ if err != nil {
+ logger.Warn("failed to create request", "error", err)
+ return nil
}
- data, err := json.Marshal(chatBody)
+ req.Header.Add("Accept", "application/json")
+ req.Header.Add("Authorization", "Bearer "+cfg.DeepSeekToken)
+ res, err := httpClient.Do(req)
if err != nil {
- panic(err)
+ logger.Warn("failed to make request", "error", err)
+ return nil
+ }
+ defer res.Body.Close()
+ resp := models.DSBalance{}
+ if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
+ return nil
}
- return bytes.NewReader(data)
+ return &resp
}
-// func sendMsgToLLM(body io.Reader) (*models.LLMRespChunk, error) {
-func sendMsgToLLM(body io.Reader) (any, error) {
- resp, err := httpClient.Post(APIURL, "application/json", body)
+func sendMsgToLLM(body io.Reader) {
+ choseChunkParser()
+ bodyBytes, _ := io.ReadAll(body)
+ ok := json.Valid(bodyBytes)
+ if !ok {
+ panic("invalid json")
+ }
+ // nolint
+ req, err := http.NewRequest("POST", cfg.CurrentAPI, bytes.NewReader(bodyBytes))
+ if err != nil {
+ logger.Error("newreq error", "error", err)
+ if err := notifyUser("error", "apicall failed:"+err.Error()); err != nil {
+ logger.Error("failed to notify", "error", err)
+ }
+ streamDone <- true
+ return
+ }
+ req.Header.Add("Accept", "application/json")
+ req.Header.Add("Content-Type", "application/json")
+ req.Header.Add("Authorization", "Bearer "+cfg.DeepSeekToken)
+ req.Header.Set("Content-Length", strconv.Itoa(len(bodyBytes)))
+ req.Header.Set("Accept-Encoding", "gzip")
+ // nolint
+ // resp, err := httpClient.Post(cfg.CurrentAPI, "application/json", body)
+ resp, err := httpClient.Do(req)
if err != nil {
logger.Error("llamacpp api", "error", err)
- return nil, err
+ if err := notifyUser("error", "apicall failed:"+err.Error()); err != nil {
+ logger.Error("failed to notify", "error", err)
+ }
+ streamDone <- true
+ return
}
defer resp.Body.Close()
- llmResp := []models.LLMRespChunk{}
- // chunkChan <- assistantIcon
reader := bufio.NewReader(resp.Body)
- counter := 0
+ counter := uint32(0)
for {
- if interruptResp {
- interruptResp = false
- logger.Info("interrupted bot response")
- break
- }
- llmchunk := models.LLMRespChunk{}
- if counter > 2000 {
+ var (
+ answerText string
+ content string
+ stop bool
+ )
+ counter++
+ // to stop from spiriling in infinity read of bad bytes that happens with poor connection
+ if cfg.ChunkLimit > 0 && counter > cfg.ChunkLimit {
+ logger.Warn("response hit chunk limit", "limit", cfg.ChunkLimit)
streamDone <- true
break
}
line, err := reader.ReadBytes('\n')
if err != nil {
+ logger.Error("error reading response body", "error", err, "line", string(line),
+ "reqbody", string(bodyBytes), "user_role", cfg.UserRole, "parser", chunkParser, "link", cfg.CurrentAPI)
+ // if err.Error() != "EOF" {
streamDone <- true
- panic(err)
+ break
+ // }
+ // continue
}
- // logger.Info("linecheck", "line", string(line), "len", len(line), "counter", counter)
if len(line) <= 1 {
+ if interruptResp {
+ goto interrupt // get unstuck from bad connection
+ }
continue // skip \n
}
// starts with -> data:
line = line[6:]
- if err := json.Unmarshal(line, &llmchunk); err != nil {
- logger.Error("failed to decode", "error", err, "line", string(line))
+ logger.Debug("debugging resp", "line", string(line))
+ if bytes.Equal(line, []byte("[DONE]\n")) {
streamDone <- true
- return nil, err
+ break
}
- llmResp = append(llmResp, llmchunk)
- // logger.Info("streamview", "chunk", llmchunk)
- // if llmchunk.Choices[len(llmchunk.Choices)-1].FinishReason != "chat.completion.chunk" {
- if llmchunk.Choices[len(llmchunk.Choices)-1].FinishReason == "stop" {
+ content, stop, err = chunkParser.ParseChunk(line)
+ if err != nil {
+ logger.Error("error parsing response body", "error", err,
+ "line", string(line), "url", cfg.CurrentAPI)
streamDone <- true
- // last chunk
break
}
- counter++
+ // Handle error messages in response content
+ if string(line) != "" && strings.Contains(strings.ToLower(string(line)), "error") {
+ logger.Error("API error response detected", "line", line, "url", cfg.CurrentAPI)
+ streamDone <- true
+ break
+ }
+ if stop {
+ if content != "" {
+ logger.Warn("text inside of finish llmchunk", "chunk", content, "counter", counter)
+ }
+ streamDone <- true
+ break
+ }
+ if counter == 0 {
+ content = strings.TrimPrefix(content, " ")
+ }
// bot sends way too many \n
- answerText := strings.ReplaceAll(llmchunk.Choices[0].Delta.Content, "\n\n", "\n")
+ answerText = strings.ReplaceAll(content, "\n\n", "\n")
chunkChan <- answerText
+ interrupt:
+ if interruptResp { // read bytes, so it would not get into beginning of the next req
+ interruptResp = false
+ logger.Info("interrupted bot response", "chunk_counter", counter)
+ streamDone <- true
+ break
+ }
}
- return llmResp, nil
}
-func chatRound(userMsg, role string, tv *tview.TextView) {
+func chatRagUse(qText string) (string, error) {
+ tokenizer, err := english.NewSentenceTokenizer(nil)
+ if err != nil {
+ return "", err
+ }
+ // TODO: this where llm should find the questions in text and ask them
+ questionsS := tokenizer.Tokenize(qText)
+ questions := make([]string, len(questionsS))
+ for i, q := range questionsS {
+ questions[i] = q.Text
+ }
+ respVecs := []models.VectorRow{}
+ for i, q := range questions {
+ emb, err := ragger.LineToVector(q)
+ if err != nil {
+ logger.Error("failed to get embs", "error", err, "index", i, "question", q)
+ continue
+ }
+ vecs, err := store.SearchClosest(emb)
+ if err != nil {
+ logger.Error("failed to query embs", "error", err, "index", i, "question", q)
+ continue
+ }
+ respVecs = append(respVecs, vecs...)
+ }
+ // get raw text
+ resps := []string{}
+ logger.Debug("sqlvec resp", "vecs len", len(respVecs))
+ for _, rv := range respVecs {
+ resps = append(resps, rv.RawText)
+ }
+ if len(resps) == 0 {
+ return "No related results from vector storage.", nil
+ }
+ return strings.Join(resps, "\n"), nil
+}
+
+func roleToIcon(role string) string {
+ return "<" + role + ">: "
+}
+
+// FIXME: it should not be here; move to extra
+func checkGame(role string, tv *tview.TextView) {
+ // Handle Cluedo game flow
+ // should go before form msg, since formmsg takes chatBody and makes ioreader out of it
+ // role is almost always user, unless it's regen or resume
+ // cannot get in this block, since cluedoState is nil;
+ if cfg.EnableCluedo {
+ // Initialize Cluedo game if needed
+ if cluedoState == nil {
+ playerOrder = []string{cfg.UserRole, cfg.AssistantRole, cfg.CluedoRole2}
+ cluedoState = extra.CluedoPrepCards(playerOrder)
+ }
+ // notifyUser("got in cluedo", "yay")
+ currentPlayer := playerOrder[0]
+ playerOrder = append(playerOrder[1:], currentPlayer) // Rotate turns
+ if role == cfg.UserRole {
+ fmt.Fprintf(tv, "Your (%s) cards: %s\n", currentPlayer, cluedoState.GetPlayerCards(currentPlayer))
+ } else {
+ chatBody.Messages = append(chatBody.Messages, models.RoleMsg{
+ Role: cfg.ToolRole,
+ Content: cluedoState.GetPlayerCards(currentPlayer),
+ })
+ }
+ }
+}
+
+func chatRound(userMsg, role string, tv *tview.TextView, regen, resume bool) {
botRespMode = true
- reader := formMsg(chatBody, userMsg, role)
+ defer func() { botRespMode = false }()
+ // check that there is a model set to use if is not local
+ if cfg.CurrentAPI == cfg.DeepSeekChatAPI || cfg.CurrentAPI == cfg.DeepSeekCompletionAPI {
+ if chatBody.Model != "deepseek-chat" && chatBody.Model != "deepseek-reasoner" {
+ if err := notifyUser("bad request", "wrong deepseek model name"); err != nil {
+ logger.Warn("failed ot notify user", "error", err)
+ return
+ }
+ return
+ }
+ }
+ if !resume {
+ checkGame(role, tv)
+ }
+ choseChunkParser()
+ reader, err := chunkParser.FormMsg(userMsg, role, resume)
+ if reader == nil || err != nil {
+ logger.Error("empty reader from msgs", "role", role, "error", err)
+ return
+ }
go sendMsgToLLM(reader)
- fmt.Fprintf(tv, fmt.Sprintf("(%d) ", len(chatBody.Messages)))
- fmt.Fprintf(tv, assistantIcon)
+ logger.Debug("looking at vars in chatRound", "msg", userMsg, "regen", regen, "resume", resume)
+ if !resume {
+ fmt.Fprintf(tv, "[-:-:b](%d) ", len(chatBody.Messages))
+ fmt.Fprint(tv, roleToIcon(cfg.AssistantRole))
+ fmt.Fprint(tv, "[-:-:-]\n")
+ if cfg.ThinkUse && !strings.Contains(cfg.CurrentAPI, "v1") {
+ // fmt.Fprint(tv, "<think>")
+ chunkChan <- "<think>"
+ }
+ }
respText := strings.Builder{}
out:
for {
select {
case chunk := <-chunkChan:
- // fmt.Printf(chunk)
- fmt.Fprintf(tv, chunk)
+ fmt.Fprint(tv, chunk)
respText.WriteString(chunk)
tv.ScrollToEnd()
+ // Send chunk to audio stream handler
+ if cfg.TTS_ENABLED {
+ // audioStream.TextChan <- chunk
+ extra.TTSTextChan <- chunk
+ }
case <-streamDone:
+ botRespMode = false
+ if cfg.TTS_ENABLED {
+ // audioStream.TextChan <- chunk
+ extra.TTSFlushChan <- true
+ logger.Info("sending flushchan signal")
+ }
break out
}
}
botRespMode = false
- chatBody.Messages = append(chatBody.Messages, models.MessagesStory{
- Role: assistantRole, Content: respText.String(),
- })
+ // numbers in chatbody and displayed must be the same
+ if resume {
+ chatBody.Messages[len(chatBody.Messages)-1].Content += respText.String()
+ // lastM.Content = lastM.Content + respText.String()
+ } else {
+ chatBody.Messages = append(chatBody.Messages, models.RoleMsg{
+ Role: cfg.AssistantRole, Content: respText.String(),
+ })
+ }
+ colorText()
+ updateStatusLine()
// bot msg is done;
// now check it for func call
// logChat(activeChatName, chatBody.Messages)
- err := updateStorageChat(activeChatName, chatBody.Messages)
- if err != nil {
+ if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil {
logger.Warn("failed to update storage", "error", err, "name", activeChatName)
}
findCall(respText.String(), tv)
}
func findCall(msg string, tv *tview.TextView) {
- prefix := "__tool_call__\n"
- suffix := "\n__tool_call__"
fc := models.FuncCall{}
- if !strings.HasPrefix(msg, prefix) ||
- !strings.HasSuffix(msg, suffix) {
+ jsStr := toolCallRE.FindString(msg)
+ if jsStr == "" {
return
}
- jsStr := strings.TrimSuffix(strings.TrimPrefix(msg, prefix), suffix)
+ prefix := "__tool_call__\n"
+ suffix := "\n__tool_call__"
+ jsStr = strings.TrimSuffix(strings.TrimPrefix(jsStr, prefix), suffix)
if err := json.Unmarshal([]byte(jsStr), &fc); err != nil {
- logger.Error("failed to unmarshal tool call", "error", err)
+ logger.Error("failed to unmarshal tool call", "error", err, "json_string", jsStr)
return
- // panic(err)
}
// call a func
f, ok := fnMap[fc.Name]
if !ok {
- m := fmt.Sprintf("%s is not implemented", fc.Name)
- chatRound(m, toolRole, tv)
+ m := fc.Name + "%s is not implemented"
+ chatRound(m, cfg.ToolRole, tv, false, false)
return
}
- resp := f(fc.Args)
- toolMsg := fmt.Sprintf("tool response: %+v", resp)
- // reader := formMsg(chatBody, toolMsg, toolRole)
- // sendMsgToLLM()
- chatRound(toolMsg, toolRole, tv)
- // return func result to the llm
+ resp := f(fc.Args...)
+ toolMsg := fmt.Sprintf("tool response: %+v", string(resp))
+ chatRound(toolMsg, cfg.ToolRole, tv, false, false)
}
func chatToTextSlice(showSys bool) []string {
resp := make([]string, len(chatBody.Messages))
for i, msg := range chatBody.Messages {
- if !showSys && (msg.Role != assistantRole && msg.Role != userRole) {
+ // INFO: skips system msg
+ if !showSys && (msg.Role != cfg.AssistantRole && msg.Role != cfg.UserRole) {
continue
}
- resp[i] = msg.ToText(i)
+ resp[i] = msg.ToText(i, cfg)
}
return resp
}
@@ -203,50 +418,118 @@ func chatToText(showSys bool) string {
return strings.Join(s, "")
}
-func textToMsg(rawMsg string) models.MessagesStory {
- msg := models.MessagesStory{}
- // system and tool?
- if strings.HasPrefix(rawMsg, assistantIcon) {
- msg.Role = assistantRole
- msg.Content = strings.TrimPrefix(rawMsg, assistantIcon)
- return msg
+func removeThinking(chatBody *models.ChatBody) {
+ msgs := []models.RoleMsg{}
+ for _, msg := range chatBody.Messages {
+ // Filter out tool messages and thinking markers
+ if msg.Role == cfg.ToolRole {
+ continue
+ }
+ // find thinking and remove it
+ rm := models.RoleMsg{
+ Role: msg.Role,
+ Content: thinkRE.ReplaceAllString(msg.Content, ""),
+ }
+ msgs = append(msgs, rm)
+ }
+ chatBody.Messages = msgs
+}
+
+func applyCharCard(cc *models.CharCard) {
+ cfg.AssistantRole = cc.Role
+ // FIXME: remove
+ // Initialize Cluedo if enabled and matching role
+ if cfg.EnableCluedo && cc.Role == "CluedoPlayer" {
+ playerOrder = []string{cfg.UserRole, cfg.AssistantRole, cfg.CluedoRole2}
+ cluedoState = extra.CluedoPrepCards(playerOrder)
}
- if strings.HasPrefix(rawMsg, userIcon) {
- msg.Role = userRole
- msg.Content = strings.TrimPrefix(rawMsg, userIcon)
- return msg
+ history, err := loadAgentsLastChat(cfg.AssistantRole)
+ if err != nil {
+ // TODO: too much action for err != nil; loadAgentsLastChat needs to be split up
+ logger.Warn("failed to load last agent chat;", "agent", cc.Role, "err", err)
+ history = []models.RoleMsg{
+ {Role: "system", Content: cc.SysPrompt},
+ {Role: cfg.AssistantRole, Content: cc.FirstMsg},
+ }
+ id, err := store.ChatGetMaxID()
+ if err != nil {
+ logger.Error("failed to get max chat id from db;", "id:", id)
+ // INFO: will rewrite first chat
+ }
+ chat := &models.Chat{
+ ID: id + 1,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ Agent: cfg.AssistantRole,
+ }
+ chat.Name = fmt.Sprintf("%d_%s", chat.ID, cfg.AssistantRole)
+ chatMap[chat.Name] = chat
+ activeChatName = chat.Name
}
- return msg
+ chatBody.Messages = history
}
-func textSliceToChat(chat []string) []models.MessagesStory {
- resp := make([]models.MessagesStory, len(chat))
- for i, rawMsg := range chat {
- msg := textToMsg(rawMsg)
- resp[i] = msg
+func charToStart(agentName string) bool {
+ cc, ok := sysMap[agentName]
+ if !ok {
+ return false
}
- return resp
+ applyCharCard(cc)
+ return true
}
func init() {
- file, err := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ cfg = config.LoadConfigOrDefault("config.toml")
+ defaultStarter = []models.RoleMsg{
+ {Role: "system", Content: basicSysMsg},
+ {Role: cfg.AssistantRole, Content: defaultFirstMsg},
+ }
+ logfile, err := os.OpenFile(cfg.LogFile,
+ os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
- panic(err)
+ logger.Error("failed to open log file", "error", err, "filename", cfg.LogFile)
+ return
}
- // create dir if does not exist
- if err := os.MkdirAll(historyDir, os.ModePerm); err != nil {
- panic(err)
+ defaultStarterBytes, err = json.Marshal(defaultStarter)
+ if err != nil {
+ logger.Error("failed to marshal defaultStarter", "error", err)
+ return
}
- logger = slog.New(slog.NewTextHandler(file, nil))
+ // load cards
+ basicCard.Role = cfg.AssistantRole
+ toolCard.Role = cfg.AssistantRole
+ //
+ logLevel.Set(slog.LevelInfo)
+ logger = slog.New(slog.NewTextHandler(logfile, &slog.HandlerOptions{Level: logLevel}))
+ // TODO: rename and/or put in cfg
store = storage.NewProviderSQL("test.db", logger)
+ if store == nil {
+ os.Exit(1)
+ }
+ ragger = rag.New(logger, store, cfg)
// https://github.com/coreydaley/ggerganov-llama.cpp/blob/master/examples/server/README.md
// load all chats in memory
- loadHistoryChats()
+ if _, err := loadHistoryChats(); err != nil {
+ logger.Error("failed to load chat", "error", err)
+ return
+ }
lastChat := loadOldChatOrGetNew()
- logger.Info("loaded history", "chat", lastChat)
chatBody = &models.ChatBody{
- Model: "modl_name",
+ Model: "modelname",
Stream: true,
Messages: lastChat,
}
+ // Initialize Cluedo if enabled and matching role
+ if cfg.EnableCluedo && cfg.AssistantRole == "CluedoPlayer" {
+ playerOrder = []string{cfg.UserRole, cfg.AssistantRole, cfg.CluedoRole2}
+ cluedoState = extra.CluedoPrepCards(playerOrder)
+ }
+ choseChunkParser()
+ httpClient = createClient(time.Second * 15)
+ if cfg.TTS_ENABLED {
+ orator = extra.NewOrator(logger, cfg)
+ }
+ if cfg.STT_ENABLED {
+ asr = extra.NewWhisperSTT(logger, cfg.STT_URL, 16000)
+ }
}
diff --git a/config.example.toml b/config.example.toml
new file mode 100644
index 0000000..229f657
--- /dev/null
+++ b/config.example.toml
@@ -0,0 +1,21 @@
+ChatAPI = "http://localhost:8080/v1/chat/completions"
+CompletionAPI = "http://localhost:8080/completion"
+EmbedURL = "http://localhost:8080/v1/embeddings"
+ShowSys = true
+LogFile = "log.txt"
+UserRole = "user"
+ToolRole = "tool"
+AssistantRole = "assistant"
+SysDir = "sysprompts"
+ChunkLimit = 100000
+# rag settings
+RAGBatchSize = 100
+RAGWordLimit = 80
+RAGWorkers = 5
+# extra tts
+TTS_ENABLED = false
+TTS_URL = "http://localhost:8880/v1/audio/speech"
+TTS_SPEED = 1.0
+# extra stt
+STT_ENABLED = false
+STT_URL = "http://localhost:8081/inference"
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..e612aa7
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,94 @@
+package config
+
+import (
+ "fmt"
+
+ "github.com/BurntSushi/toml"
+)
+
+type Config struct {
+ EnableCluedo bool `toml:"EnableCluedo"` // Cluedo game mode toggle
+ CluedoRole2 string `toml:"CluedoRole2"` // Secondary AI role name
+ ChatAPI string `toml:"ChatAPI"`
+ CompletionAPI string `toml:"CompletionAPI"`
+ CurrentAPI string
+ CurrentProvider string
+ APIMap map[string]string
+ //
+ ShowSys bool `toml:"ShowSys"`
+ LogFile string `toml:"LogFile"`
+ UserRole string `toml:"UserRole"`
+ ToolRole string `toml:"ToolRole"`
+ ToolUse bool `toml:"ToolUse"`
+ ThinkUse bool `toml:"ThinkUse"`
+ AssistantRole string `toml:"AssistantRole"`
+ SysDir string `toml:"SysDir"`
+ ChunkLimit uint32 `toml:"ChunkLimit"`
+ // embeddings
+ RAGEnabled bool `toml:"RAGEnabled"`
+ EmbedURL string `toml:"EmbedURL"`
+ HFToken string `toml:"HFToken"`
+ RAGDir string `toml:"RAGDir"`
+ // rag settings
+ RAGWorkers uint32 `toml:"RAGWorkers"`
+ RAGBatchSize int `toml:"RAGBatchSize"`
+ RAGWordLimit uint32 `toml:"RAGWordLimit"`
+ // deepseek
+ DeepSeekChatAPI string `toml:"DeepSeekChatAPI"`
+ DeepSeekCompletionAPI string `toml:"DeepSeekCompletionAPI"`
+ DeepSeekToken string `toml:"DeepSeekToken"`
+ DeepSeekModel string `toml:"DeepSeekModel"`
+ ApiLinks []string
+ // TTS
+ TTS_URL string `toml:"TTS_URL"`
+ TTS_ENABLED bool `toml:"TTS_ENABLED"`
+ TTS_SPEED float32 `toml:"TTS_SPEED"`
+ // STT
+ STT_URL string `toml:"STT_URL"`
+ STT_ENABLED bool `toml:"STT_ENABLED"`
+}
+
+func LoadConfigOrDefault(fn string) *Config {
+ if fn == "" {
+ fn = "config.toml"
+ }
+ config := &Config{}
+ _, err := toml.DecodeFile(fn, &config)
+ if err != nil {
+ fmt.Println("failed to read config from file, loading default", "error", err)
+ config.ChatAPI = "http://localhost:8080/v1/chat/completions"
+ config.CompletionAPI = "http://localhost:8080/completion"
+ config.DeepSeekCompletionAPI = "https://api.deepseek.com/beta/completions"
+ config.DeepSeekChatAPI = "https://api.deepseek.com/chat/completions"
+ config.RAGEnabled = false
+ config.EmbedURL = "http://localhost:8080/v1/embiddings"
+ config.ShowSys = true
+ config.LogFile = "log.txt"
+ config.UserRole = "user"
+ config.ToolRole = "tool"
+ config.AssistantRole = "assistant"
+ config.SysDir = "sysprompts"
+ config.ChunkLimit = 8192
+ //
+ config.RAGBatchSize = 100
+ config.RAGWordLimit = 80
+ config.RAGWorkers = 5
+ // tts
+ config.TTS_ENABLED = false
+ config.TTS_URL = "http://localhost:8880/v1/audio/speech"
+ }
+ config.CurrentAPI = config.ChatAPI
+ config.APIMap = map[string]string{
+ config.ChatAPI: config.CompletionAPI,
+ config.CompletionAPI: config.DeepSeekChatAPI,
+ config.DeepSeekChatAPI: config.DeepSeekCompletionAPI,
+ config.DeepSeekCompletionAPI: config.ChatAPI,
+ }
+ for _, el := range []string{config.ChatAPI, config.CompletionAPI, config.DeepSeekChatAPI, config.DeepSeekCompletionAPI} {
+ if el != "" {
+ config.ApiLinks = append(config.ApiLinks, el)
+ }
+ }
+ // if any value is empty fill with default
+ return config
+}
diff --git a/extra/cluedo.go b/extra/cluedo.go
new file mode 100644
index 0000000..1ef11cc
--- /dev/null
+++ b/extra/cluedo.go
@@ -0,0 +1,73 @@
+package extra
+
+import (
+ "math/rand"
+ "strings"
+)
+
+var (
+ rooms = []string{"HALL", "LOUNGE", "DINING ROOM", "KITCHEN", "BALLROOM", "CONSERVATORY", "BILLIARD ROOM", "LIBRARY", "STUDY"}
+ weapons = []string{"CANDLESTICK", "DAGGER", "LEAD PIPE", "REVOLVER", "ROPE", "SPANNER"}
+ people = []string{"Miss Scarlett", "Colonel Mustard", "Mrs. White", "Reverend Green", "Mrs. Peacock", "Professor Plum"}
+)
+
+type MurderTrifecta struct {
+ Murderer string
+ Weapon string
+ Room string
+}
+
+type CluedoRoundInfo struct {
+ Answer MurderTrifecta
+ PlayersCards map[string][]string
+}
+
+func (c *CluedoRoundInfo) GetPlayerCards(player string) string {
+ // maybe format it a little
+ return "cards of " + player + "are " + strings.Join(c.PlayersCards[player], ",")
+}
+
+func CluedoPrepCards(playerOrder []string) *CluedoRoundInfo {
+ res := &CluedoRoundInfo{}
+ // Select murder components
+ trifecta := MurderTrifecta{
+ Murderer: people[rand.Intn(len(people))],
+ Weapon: weapons[rand.Intn(len(weapons))],
+ Room: rooms[rand.Intn(len(rooms))],
+ }
+ // Collect non-murder cards
+ var notInvolved []string
+ for _, room := range rooms {
+ if room != trifecta.Room {
+ notInvolved = append(notInvolved, room)
+ }
+ }
+ for _, weapon := range weapons {
+ if weapon != trifecta.Weapon {
+ notInvolved = append(notInvolved, weapon)
+ }
+ }
+ for _, person := range people {
+ if person != trifecta.Murderer {
+ notInvolved = append(notInvolved, person)
+ }
+ }
+ // Shuffle and distribute cards
+ rand.Shuffle(len(notInvolved), func(i, j int) {
+ notInvolved[i], notInvolved[j] = notInvolved[j], notInvolved[i]
+ })
+ players := map[string][]string{}
+ cardsPerPlayer := len(notInvolved) / len(playerOrder)
+ // playerOrder := []string{"{{user}}", "{{char}}", "{{char2}}"}
+ for i, player := range playerOrder {
+ start := i * cardsPerPlayer
+ end := (i + 1) * cardsPerPlayer
+ if end > len(notInvolved) {
+ end = len(notInvolved)
+ }
+ players[player] = notInvolved[start:end]
+ }
+ res.Answer = trifecta
+ res.PlayersCards = players
+ return res
+}
diff --git a/extra/cluedo_test.go b/extra/cluedo_test.go
new file mode 100644
index 0000000..e7a53b1
--- /dev/null
+++ b/extra/cluedo_test.go
@@ -0,0 +1,50 @@
+package extra
+
+import (
+ "testing"
+)
+
+func TestPrepCards(t *testing.T) {
+ // Run the function to get the murder combination and player cards
+ roundInfo := CluedoPrepCards([]string{"{{user}}", "{{char}}", "{{char2}}"})
+ // Create a map to track all distributed cards
+ distributedCards := make(map[string]bool)
+ // Check that the murder combination cards are not distributed to players
+ murderCards := []string{roundInfo.Answer.Murderer, roundInfo.Answer.Weapon, roundInfo.Answer.Room}
+ for _, card := range murderCards {
+ if distributedCards[card] {
+ t.Errorf("Murder card %s was distributed to a player", card)
+ }
+ }
+ // Check each player's cards
+ for player, cards := range roundInfo.PlayersCards {
+ for _, card := range cards {
+ // Ensure the card is not part of the murder combination
+ for _, murderCard := range murderCards {
+ if card == murderCard {
+ t.Errorf("Player %s has a murder card: %s", player, card)
+ }
+ }
+ // Ensure the card is unique and not already distributed
+ if distributedCards[card] {
+ t.Errorf("Card %s is duplicated in player %s's hand", card, player)
+ }
+ distributedCards[card] = true
+ }
+ }
+ // Verify that all non-murder cards are distributed
+ allCards := append(append([]string{}, rooms...), weapons...)
+ allCards = append(allCards, people...)
+ for _, card := range allCards {
+ isMurderCard := false
+ for _, murderCard := range murderCards {
+ if card == murderCard {
+ isMurderCard = true
+ break
+ }
+ }
+ if !isMurderCard && !distributedCards[card] {
+ t.Errorf("Card %s was not distributed to any player", card)
+ }
+ }
+}
diff --git a/extra/stt.go b/extra/stt.go
new file mode 100644
index 0000000..ce107b4
--- /dev/null
+++ b/extra/stt.go
@@ -0,0 +1,166 @@
+package extra
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "mime/multipart"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/gordonklaus/portaudio"
+)
+
+var specialRE = regexp.MustCompile(`\[.*?\]`)
+
+type STT interface {
+ StartRecording() error
+ StopRecording() (string, error)
+ IsRecording() bool
+}
+
+type StreamCloser interface {
+ Close() error
+}
+
+type WhisperSTT struct {
+ logger *slog.Logger
+ ServerURL string
+ SampleRate int
+ AudioBuffer *bytes.Buffer
+ recording bool
+}
+
+func NewWhisperSTT(logger *slog.Logger, serverURL string, sampleRate int) *WhisperSTT {
+ return &WhisperSTT{
+ logger: logger,
+ ServerURL: serverURL,
+ SampleRate: sampleRate,
+ AudioBuffer: new(bytes.Buffer),
+ }
+}
+
+func (stt *WhisperSTT) StartRecording() error {
+ if err := stt.microphoneStream(stt.SampleRate); err != nil {
+ return fmt.Errorf("failed to init microphone: %w", err)
+ }
+ stt.recording = true
+ return nil
+}
+
+func (stt *WhisperSTT) StopRecording() (string, error) {
+ stt.recording = false
+ // wait loop to finish?
+ if stt.AudioBuffer == nil {
+ err := errors.New("unexpected nil AudioBuffer")
+ stt.logger.Error(err.Error())
+ return "", err
+ }
+ // Create WAV header first
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+ // Add audio file part
+ part, err := writer.CreateFormFile("file", "recording.wav")
+ if err != nil {
+ stt.logger.Error("fn: StopRecording", "error", err)
+ return "", err
+ }
+ // Stream directly to multipart writer: header + raw data
+ dataSize := stt.AudioBuffer.Len()
+ stt.writeWavHeader(part, dataSize)
+ if _, err := io.Copy(part, stt.AudioBuffer); err != nil {
+ stt.logger.Error("fn: StopRecording", "error", err)
+ return "", err
+ }
+ // Reset buffer for next recording
+ stt.AudioBuffer.Reset()
+ // Add response format field
+ err = writer.WriteField("response_format", "text")
+ if err != nil {
+ stt.logger.Error("fn: StopRecording", "error", err)
+ return "", err
+ }
+ if writer.Close() != nil {
+ stt.logger.Error("fn: StopRecording", "error", err)
+ return "", err
+ }
+ // Send request
+ resp, err := http.Post(stt.ServerURL, writer.FormDataContentType(), body) //nolint:noctx
+ if err != nil {
+ stt.logger.Error("fn: StopRecording", "error", err)
+ return "", err
+ }
+ defer resp.Body.Close()
+ // Read and print response
+ responseTextBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ stt.logger.Error("fn: StopRecording", "error", err)
+ return "", err
+ }
+ resptext := strings.TrimRight(string(responseTextBytes), "\n")
+ // in case there are special tokens like [_BEG_]
+ resptext = specialRE.ReplaceAllString(resptext, "")
+ return strings.TrimSpace(strings.ReplaceAll(resptext, "\n ", "\n")), nil
+}
+
+func (stt *WhisperSTT) writeWavHeader(w io.Writer, dataSize int) {
+ header := make([]byte, 44)
+ copy(header[0:4], "RIFF")
+ binary.LittleEndian.PutUint32(header[4:8], uint32(36+dataSize))
+ copy(header[8:12], "WAVE")
+ copy(header[12:16], "fmt ")
+ binary.LittleEndian.PutUint32(header[16:20], 16)
+ binary.LittleEndian.PutUint16(header[20:22], 1)
+ binary.LittleEndian.PutUint16(header[22:24], 1)
+ binary.LittleEndian.PutUint32(header[24:28], uint32(stt.SampleRate))
+ binary.LittleEndian.PutUint32(header[28:32], uint32(stt.SampleRate)*1*(16/8))
+ binary.LittleEndian.PutUint16(header[32:34], 1*(16/8))
+ binary.LittleEndian.PutUint16(header[34:36], 16)
+ copy(header[36:40], "data")
+ binary.LittleEndian.PutUint32(header[40:44], uint32(dataSize))
+ if _, err := w.Write(header); err != nil {
+ stt.logger.Error("writeWavHeader", "error", err)
+ }
+}
+
+func (stt *WhisperSTT) IsRecording() bool {
+ return stt.recording
+}
+
+func (stt *WhisperSTT) microphoneStream(sampleRate int) error {
+ if err := portaudio.Initialize(); err != nil {
+ return fmt.Errorf("portaudio init failed: %w", err)
+ }
+ in := make([]int16, 64)
+ stream, err := portaudio.OpenDefaultStream(1, 0, float64(sampleRate), len(in), in)
+ if err != nil {
+ if paErr := portaudio.Terminate(); paErr != nil {
+ return fmt.Errorf("failed to open microphone: %w; terminate error: %w", err, paErr)
+ }
+ return fmt.Errorf("failed to open microphone: %w", err)
+ }
+ go func(stream *portaudio.Stream) {
+ if err := stream.Start(); err != nil {
+ stt.logger.Error("microphoneStream", "error", err)
+ return
+ }
+ for {
+ if !stt.IsRecording() {
+ return
+ }
+ if err := stream.Read(); err != nil {
+ stt.logger.Error("reading stream", "error", err)
+ return
+ }
+ if err := binary.Write(stt.AudioBuffer, binary.LittleEndian, in); err != nil {
+ stt.logger.Error("writing to buffer", "error", err)
+ return
+ }
+ }
+ }(stream)
+ return nil
+}
diff --git a/extra/tts.go b/extra/tts.go
new file mode 100644
index 0000000..31e6887
--- /dev/null
+++ b/extra/tts.go
@@ -0,0 +1,212 @@
+package extra
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "gf-lt/config"
+ "gf-lt/models"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gopxl/beep/v2"
+ "github.com/gopxl/beep/v2/mp3"
+ "github.com/gopxl/beep/v2/speaker"
+ "github.com/neurosnap/sentences/english"
+)
+
+var (
+ TTSTextChan = make(chan string, 10000)
+ TTSFlushChan = make(chan bool, 1)
+ TTSDoneChan = make(chan bool, 1)
+ // endsWithPunctuation = regexp.MustCompile(`[;.!?]$`)
+)
+
+type Orator interface {
+ Speak(text string) error
+ Stop()
+ // pause and resume?
+ GetLogger() *slog.Logger
+}
+
+// impl https://github.com/remsky/Kokoro-FastAPI
+type KokoroOrator struct {
+ logger *slog.Logger
+ URL string
+ Format models.AudioFormat
+ Stream bool
+ Speed float32
+ Language string
+ Voice string
+ currentStream *beep.Ctrl // Added for playback control
+ textBuffer strings.Builder
+ // textBuffer bytes.Buffer
+}
+
+func (o *KokoroOrator) stoproutine() {
+ <-TTSDoneChan
+ o.logger.Info("orator got done signal")
+ o.Stop()
+ // drain the channel
+ for len(TTSTextChan) > 0 {
+ <-TTSTextChan
+ }
+}
+
+func (o *KokoroOrator) readroutine() {
+ tokenizer, _ := english.NewSentenceTokenizer(nil)
+ // var sentenceBuf bytes.Buffer
+ // var remainder strings.Builder
+ for {
+ select {
+ case chunk := <-TTSTextChan:
+ // sentenceBuf.WriteString(chunk)
+ // text := sentenceBuf.String()
+ _, err := o.textBuffer.WriteString(chunk)
+ if err != nil {
+ o.logger.Warn("failed to write to stringbuilder", "error", err)
+ continue
+ }
+ text := o.textBuffer.String()
+ sentences := tokenizer.Tokenize(text)
+ o.logger.Info("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences))
+ for i, sentence := range sentences {
+ if i == len(sentences)-1 { // last sentence
+ o.textBuffer.Reset()
+ _, err := o.textBuffer.WriteString(sentence.Text)
+ if err != nil {
+ o.logger.Warn("failed to write to stringbuilder", "error", err)
+ continue
+ }
+ continue // if only one (often incomplete) sentence; wait for next chunk
+ }
+ o.logger.Info("calling Speak with sentence", "sent", sentence.Text)
+ if err := o.Speak(sentence.Text); err != nil {
+ o.logger.Error("tts failed", "sentence", sentence.Text, "error", err)
+ }
+ }
+ case <-TTSFlushChan:
+ o.logger.Info("got flushchan signal start")
+ // lln is done get the whole message out
+ if len(TTSTextChan) > 0 { // otherwise might get stuck
+ for chunk := range TTSTextChan {
+ _, err := o.textBuffer.WriteString(chunk)
+ if err != nil {
+ o.logger.Warn("failed to write to stringbuilder", "error", err)
+ continue
+ }
+ if len(TTSTextChan) == 0 {
+ break
+ }
+ }
+ }
+ // INFO: if there is a lot of text it will take some time to make with tts at once
+ // to avoid this pause, it might be better to keep splitting on sentences
+ // but keepinig in mind that remainder could be ommited by tokenizer
+ // Flush remaining text
+ remaining := o.textBuffer.String()
+ o.textBuffer.Reset()
+ if remaining != "" {
+ o.logger.Info("calling Speak with remainder", "rem", remaining)
+ if err := o.Speak(remaining); err != nil {
+ o.logger.Error("tts failed", "sentence", remaining, "error", err)
+ }
+ }
+ }
+ }
+}
+
+func NewOrator(log *slog.Logger, cfg *config.Config) Orator {
+ orator := &KokoroOrator{
+ logger: log,
+ URL: cfg.TTS_URL,
+ Format: models.AFMP3,
+ Stream: false,
+ Speed: cfg.TTS_SPEED,
+ Language: "a",
+ Voice: "af_bella(1)+af_sky(1)",
+ }
+ go orator.readroutine()
+ go orator.stoproutine()
+ return orator
+}
+
+func (o *KokoroOrator) GetLogger() *slog.Logger {
+ return o.logger
+}
+
+func (o *KokoroOrator) requestSound(text string) (io.ReadCloser, error) {
+ payload := map[string]interface{}{
+ "input": text,
+ "voice": o.Voice,
+ "response_format": o.Format,
+ "download_format": o.Format,
+ "stream": o.Stream,
+ "speed": o.Speed,
+ // "return_download_link": true,
+ "lang_code": o.Language,
+ }
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal payload: %w", err)
+ }
+ req, err := http.NewRequest("POST", o.URL, bytes.NewBuffer(payloadBytes)) //nolint:noctx
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ defer resp.Body.Close()
+ return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+ return resp.Body, nil
+}
+
+func (o *KokoroOrator) Speak(text string) error {
+ o.logger.Info("fn: Speak is called", "text-len", len(text))
+ body, err := o.requestSound(text)
+ if err != nil {
+ o.logger.Error("request failed", "error", err)
+ return fmt.Errorf("request failed: %w", err)
+ }
+ defer body.Close()
+ // Decode the mp3 audio from response body
+ streamer, format, err := mp3.Decode(body)
+ if err != nil {
+ o.logger.Error("mp3 decode failed", "error", err)
+ return fmt.Errorf("mp3 decode failed: %w", err)
+ }
+ defer streamer.Close()
+ // here it spams with errors that speaker cannot be initialized more than once, but how would we deal with many audio records then?
+ if err := speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)); err != nil {
+ o.logger.Debug("failed to init speaker", "error", err)
+ }
+ done := make(chan bool)
+ // Create controllable stream and store reference
+ o.currentStream = &beep.Ctrl{Streamer: beep.Seq(streamer, beep.Callback(func() {
+ close(done)
+ o.currentStream = nil
+ })), Paused: false}
+ speaker.Play(o.currentStream)
+ <-done // we hang in this routine;
+ return nil
+}
+
+func (o *KokoroOrator) Stop() {
+ // speaker.Clear()
+ o.logger.Info("attempted to stop orator", "orator", o)
+ speaker.Lock()
+ defer speaker.Unlock()
+ if o.currentStream != nil {
+ // o.currentStream.Paused = true
+ o.currentStream.Streamer = nil
+ }
+}
diff --git a/extra/twentyq.go b/extra/twentyq.go
new file mode 100644
index 0000000..30c08cc
--- /dev/null
+++ b/extra/twentyq.go
@@ -0,0 +1,11 @@
+package extra
+
+import "math/rand"
+
+var (
+ chars = []string{"Shrek", "Garfield", "Jack the Ripper"}
+)
+
+func GetRandomChar() string {
+ return chars[rand.Intn(len(chars))]
+}
diff --git a/extra/vad.go b/extra/vad.go
new file mode 100644
index 0000000..2a9e238
--- /dev/null
+++ b/extra/vad.go
@@ -0,0 +1 @@
+package extra
diff --git a/go.mod b/go.mod
index 687511d..cc1e743 100644
--- a/go.mod
+++ b/go.mod
@@ -1,26 +1,38 @@
-module elefant
+module gf-lt
go 1.23.2
require (
+ github.com/BurntSushi/toml v1.4.0
+ github.com/asg017/sqlite-vec-go-bindings v0.1.6
github.com/gdamore/tcell/v2 v2.7.4
github.com/glebarez/go-sqlite v1.22.0
+ github.com/gopxl/beep/v2 v2.1.0
+ github.com/gordonklaus/portaudio v0.0.0-20230709114228-aafa478834f5
github.com/jmoiron/sqlx v1.4.0
+ github.com/ncruces/go-sqlite3 v0.21.3
+ github.com/neurosnap/sentences v1.1.2
github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/ebitengine/oto/v3 v3.2.0 // indirect
+ github.com/ebitengine/purego v0.7.1 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
- github.com/google/uuid v1.5.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
+ github.com/ncruces/julianday v1.0.0 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
- golang.org/x/sys v0.17.0 // indirect
+ github.com/tetratelabs/wazero v1.8.2 // indirect
+ golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.17.0 // indirect
- golang.org/x/text v0.14.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
modernc.org/libc v1.37.6 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
diff --git a/go.sum b/go.sum
index e4a23b5..1fffadf 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,17 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
+github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/asg017/sqlite-vec-go-bindings v0.1.6 h1:Nx0jAzyS38XpkKznJ9xQjFXz2X9tI7KqjwVxV8RNoww=
+github.com/asg017/sqlite-vec-go-bindings v0.1.6/go.mod h1:A8+cTt/nKFsYCQF6OgzSNpKZrzNo5gQsXBTfsXHXY0Q=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/ebitengine/oto/v3 v3.2.0 h1:FuggTJTSI3/3hEYwZEIN0CZVXYT29ZOdCu+z/f4QjTw=
+github.com/ebitengine/oto/v3 v3.2.0/go.mod h1:dOKXShvy1EQbIXhXPFcKLargdnFqH0RjptecvyAxhyw=
+github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
+github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
@@ -12,8 +22,15 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
-github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
-github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gopxl/beep/v2 v2.1.0 h1:Jv95iHw3aNWoAa/J78YyXvOvMHH2ZGeAYD5ug8tVt8c=
+github.com/gopxl/beep/v2 v2.1.0/go.mod h1:sQvj2oSsu8fmmDWH3t0DzIe0OZzTW6/TJEHW4Ku+22o=
+github.com/gordonklaus/portaudio v0.0.0-20230709114228-aafa478834f5 h1:5AlozfqaVjGYGhms2OsdUyfdJME76E6rx5MdGpjzZpc=
+github.com/gordonklaus/portaudio v0.0.0-20230709114228-aafa478834f5/go.mod h1:WY8R6YKlI2ZI3UyzFk7P6yGSuS+hFwNtEzrexRyD7Es=
+github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
+github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
+github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -26,6 +43,16 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/ncruces/go-sqlite3 v0.21.3 h1:hHkfNQLcbnxPJZhC/RGw9SwP3bfkv/Y0xUHWsr1CdMQ=
+github.com/ncruces/go-sqlite3 v0.21.3/go.mod h1:zxMOaSG5kFYVFK4xQa0pdwIszqxqJ0W0BxBgwdrNjuA=
+github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
+github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
+github.com/neurosnap/sentences v1.1.2 h1:iphYOzx/XckXeBiLIUBkPu2EKMJ+6jDbz/sLJZ7ZoUw=
+github.com/neurosnap/sentences v1.1.2/go.mod h1:/pwU4E9XNL21ygMIkOIllv/SMy2ujHwpf8GQPu1YPbQ=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592 h1:YIJ+B1hePP6AgynC5TcqpO0H9k3SSoZa2BGyL6vDUzM=
@@ -34,6 +61,10 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4=
+github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -50,11 +81,13 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -64,13 +97,16 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
diff --git a/llm.go b/llm.go
new file mode 100644
index 0000000..3307467
--- /dev/null
+++ b/llm.go
@@ -0,0 +1,288 @@
+package main
+
+import (
+ "bytes"
+ "gf-lt/models"
+ "encoding/json"
+ "io"
+ "strings"
+)
+
+type ChunkParser interface {
+ ParseChunk([]byte) (string, bool, error)
+ FormMsg(msg, role string, cont bool) (io.Reader, error)
+}
+
+func choseChunkParser() {
+ chunkParser = LlamaCPPeer{}
+ switch cfg.CurrentAPI {
+ case "http://localhost:8080/completion":
+ chunkParser = LlamaCPPeer{}
+ logger.Debug("chosen llamacppeer", "link", cfg.CurrentAPI)
+ return
+ case "http://localhost:8080/v1/chat/completions":
+ chunkParser = OpenAIer{}
+ logger.Debug("chosen openair", "link", cfg.CurrentAPI)
+ return
+ case "https://api.deepseek.com/beta/completions":
+ chunkParser = DeepSeekerCompletion{}
+ logger.Debug("chosen deepseekercompletio", "link", cfg.CurrentAPI)
+ return
+ case "https://api.deepseek.com/chat/completions":
+ chunkParser = DeepSeekerChat{}
+ logger.Debug("chosen deepseekerchat", "link", cfg.CurrentAPI)
+ return
+ default:
+ chunkParser = LlamaCPPeer{}
+ }
+ // if strings.Contains(cfg.CurrentAPI, "chat") {
+ // logger.Debug("chosen chat parser")
+ // chunkParser = OpenAIer{}
+ // return
+ // }
+ // logger.Debug("chosen llamacpp /completion parser")
+}
+
+type LlamaCPPeer struct {
+}
+type OpenAIer struct {
+}
+type DeepSeekerCompletion struct {
+}
+type DeepSeekerChat struct {
+}
+
+func (lcp LlamaCPPeer) FormMsg(msg, role string, resume bool) (io.Reader, error) {
+ logger.Debug("formmsg llamacppeer", "link", cfg.CurrentAPI)
+ if msg != "" { // otherwise let the bot to continue
+ newMsg := models.RoleMsg{Role: role, Content: msg}
+ 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, err
+ }
+ ragMsg := models.RoleMsg{Role: cfg.ToolRole, Content: ragResp}
+ chatBody.Messages = append(chatBody.Messages, ragMsg)
+ }
+ }
+ if cfg.ToolUse && !resume {
+ // add to chat body
+ chatBody.Messages = append(chatBody.Messages, models.RoleMsg{Role: cfg.ToolRole, Content: toolSysMsg})
+ }
+ messages := make([]string, len(chatBody.Messages))
+ for i, m := range chatBody.Messages {
+ messages[i] = m.ToPrompt()
+ }
+ prompt := strings.Join(messages, "\n")
+ // strings builder?
+ if !resume {
+ botMsgStart := "\n" + cfg.AssistantRole + ":\n"
+ prompt += botMsgStart
+ }
+ if cfg.ThinkUse && !cfg.ToolUse {
+ prompt += "<think>"
+ }
+ logger.Debug("checking prompt for /completion", "tool_use", cfg.ToolUse,
+ "msg", msg, "resume", resume, "prompt", prompt)
+ var payload any
+ payload = models.NewLCPReq(prompt, cfg, defaultLCPProps)
+ if strings.Contains(chatBody.Model, "deepseek") {
+ payload = models.NewDSCompletionReq(prompt, chatBody.Model,
+ defaultLCPProps["temp"], cfg)
+ }
+ data, err := json.Marshal(payload)
+ if err != nil {
+ logger.Error("failed to form a msg", "error", err)
+ return nil, err
+ }
+ return bytes.NewReader(data), nil
+}
+
+func (lcp LlamaCPPeer) ParseChunk(data []byte) (string, bool, error) {
+ llmchunk := models.LlamaCPPResp{}
+ if err := json.Unmarshal(data, &llmchunk); err != nil {
+ logger.Error("failed to decode", "error", err, "line", string(data))
+ return "", false, err
+ }
+ if llmchunk.Stop {
+ if llmchunk.Content != "" {
+ logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
+ }
+ return llmchunk.Content, true, nil
+ }
+ return llmchunk.Content, false, nil
+}
+
+func (op OpenAIer) ParseChunk(data []byte) (string, bool, error) {
+ llmchunk := models.LLMRespChunk{}
+ if err := json.Unmarshal(data, &llmchunk); err != nil {
+ logger.Error("failed to decode", "error", err, "line", string(data))
+ return "", false, err
+ }
+ content := llmchunk.Choices[len(llmchunk.Choices)-1].Delta.Content
+ if llmchunk.Choices[len(llmchunk.Choices)-1].FinishReason == "stop" {
+ if content != "" {
+ logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
+ }
+ return content, true, nil
+ }
+ return content, false, nil
+}
+
+func (op OpenAIer) FormMsg(msg, role string, resume bool) (io.Reader, error) {
+ logger.Debug("formmsg openaier", "link", cfg.CurrentAPI)
+ if cfg.ToolUse && !resume {
+ // prompt += "\n" + cfg.ToolRole + ":\n" + toolSysMsg
+ // add to chat body
+ chatBody.Messages = append(chatBody.Messages, models.RoleMsg{Role: cfg.ToolRole, Content: toolSysMsg})
+ }
+ if msg != "" { // otherwise let the bot continue
+ newMsg := models.RoleMsg{Role: role, Content: msg}
+ 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, err
+ }
+ 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, err
+ }
+ return bytes.NewReader(data), nil
+}
+
+// deepseek
+func (ds DeepSeekerCompletion) ParseChunk(data []byte) (string, bool, error) {
+ llmchunk := models.DSCompletionResp{}
+ if err := json.Unmarshal(data, &llmchunk); err != nil {
+ logger.Error("failed to decode", "error", err, "line", string(data))
+ return "", false, err
+ }
+ if llmchunk.Choices[0].FinishReason != "" {
+ if llmchunk.Choices[0].Text != "" {
+ logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
+ }
+ return llmchunk.Choices[0].Text, true, nil
+ }
+ return llmchunk.Choices[0].Text, false, nil
+}
+
+func (ds DeepSeekerCompletion) FormMsg(msg, role string, resume bool) (io.Reader, error) {
+ logger.Debug("formmsg deepseekercompletion", "link", cfg.CurrentAPI)
+ if msg != "" { // otherwise let the bot to continue
+ newMsg := models.RoleMsg{Role: role, Content: msg}
+ 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, err
+ }
+ ragMsg := models.RoleMsg{Role: cfg.ToolRole, Content: ragResp}
+ chatBody.Messages = append(chatBody.Messages, ragMsg)
+ }
+ }
+ if cfg.ToolUse && !resume {
+ // add to chat body
+ chatBody.Messages = append(chatBody.Messages, models.RoleMsg{Role: cfg.ToolRole, Content: toolSysMsg})
+ }
+ messages := make([]string, len(chatBody.Messages))
+ for i, m := range chatBody.Messages {
+ messages[i] = m.ToPrompt()
+ }
+ prompt := strings.Join(messages, "\n")
+ // strings builder?
+ if !resume {
+ botMsgStart := "\n" + cfg.AssistantRole + ":\n"
+ prompt += botMsgStart
+ }
+ if cfg.ThinkUse && !cfg.ToolUse {
+ prompt += "<think>"
+ }
+ logger.Debug("checking prompt for /completion", "tool_use", cfg.ToolUse,
+ "msg", msg, "resume", resume, "prompt", prompt)
+ payload := models.NewDSCompletionReq(prompt, chatBody.Model,
+ defaultLCPProps["temp"], cfg)
+ data, err := json.Marshal(payload)
+ if err != nil {
+ logger.Error("failed to form a msg", "error", err)
+ return nil, err
+ }
+ return bytes.NewReader(data), nil
+}
+
+func (ds DeepSeekerChat) ParseChunk(data []byte) (string, bool, error) {
+ llmchunk := models.DSChatStreamResp{}
+ if err := json.Unmarshal(data, &llmchunk); err != nil {
+ logger.Error("failed to decode", "error", err, "line", string(data))
+ return "", false, err
+ }
+ if llmchunk.Choices[0].FinishReason != "" {
+ if llmchunk.Choices[0].Delta.Content != "" {
+ logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
+ }
+ return llmchunk.Choices[0].Delta.Content, true, nil
+ }
+ if llmchunk.Choices[0].Delta.ReasoningContent != "" {
+ return llmchunk.Choices[0].Delta.ReasoningContent, false, nil
+ }
+ return llmchunk.Choices[0].Delta.Content, false, nil
+}
+
+func (ds DeepSeekerChat) FormMsg(msg, role string, resume bool) (io.Reader, error) {
+ logger.Debug("formmsg deepseekerchat", "link", cfg.CurrentAPI)
+ if cfg.ToolUse && !resume {
+ // prompt += "\n" + cfg.ToolRole + ":\n" + toolSysMsg
+ // add to chat body
+ chatBody.Messages = append(chatBody.Messages, models.RoleMsg{Role: cfg.ToolRole, Content: toolSysMsg})
+ }
+ if msg != "" { // otherwise let the bot continue
+ newMsg := models.RoleMsg{Role: role, Content: msg}
+ 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, err
+ }
+ ragMsg := models.RoleMsg{Role: cfg.ToolRole, Content: ragResp}
+ chatBody.Messages = append(chatBody.Messages, ragMsg)
+ }
+ }
+ // Create copy of chat body with standardized user role
+ // modifiedBody := *chatBody
+ bodyCopy := &models.ChatBody{
+ Messages: make([]models.RoleMsg, len(chatBody.Messages)),
+ Model: chatBody.Model,
+ Stream: chatBody.Stream,
+ }
+ // modifiedBody.Messages = make([]models.RoleMsg, len(chatBody.Messages))
+ for i, msg := range chatBody.Messages {
+ logger.Debug("checking roles", "#", i, "role", msg.Role)
+ if msg.Role == cfg.UserRole || i == 1 {
+ bodyCopy.Messages[i].Role = "user"
+ logger.Debug("replaced role in body", "#", i)
+ } else {
+ bodyCopy.Messages[i] = msg
+ }
+ }
+ dsBody := models.NewDSCharReq(*bodyCopy)
+ data, err := json.Marshal(dsBody)
+ if err != nil {
+ logger.Error("failed to form a msg", "error", err)
+ return nil, err
+ }
+ return bytes.NewReader(data), nil
+}
diff --git a/main.go b/main.go
index 1dc387a..c73cf3c 100644
--- a/main.go
+++ b/main.go
@@ -1,22 +1,18 @@
package main
import (
- "fmt"
- "path"
+ "flag"
"strconv"
- "time"
"unicode"
- "github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
var (
botRespMode = false
editMode = false
- botMsg = "no"
selectedIndex = int(-1)
- indexLine = "Esc: send msg; PgUp/Down: switch focus; F1: manage chats; F2: regen last; F3:delete last msg; F4: edit msg; F5: toggle system; F6: interrupt bot resp; Row: [yellow]%d[white], Column: [yellow]%d; bot resp mode: %v"
+ indexLine = "F12 to show keys help | bot resp mode: [orange:-:b]%v[-:-:-] (F6) | char: [orange:-:b]%s[-:-:-] (ctrl+s) | chat: [orange:-:b]%s[-:-:-] (F1) | toolUseAdviced: [orange:-:b]%v[-:-:-] (ctrl+k) | model: [orange:-:b]%s[-:-:-] (ctrl+l)\nAPI_URL: [orange:-:b]%s[-:-:-] (ctrl+v) | ThinkUse: [orange:-:b]%v[-:-:-] (ctrl+p) | Log Level: [orange:-:b]%v[-:-:-] (ctrl+p) | Recording: [orange:-:b]%v[-:-:-] (ctrl+r)"
focusSwitcher = map[tview.Primitive]tview.Primitive{}
)
@@ -30,216 +26,17 @@ func isASCII(s string) bool {
}
func main() {
- app := tview.NewApplication()
- pages := tview.NewPages()
- textArea := tview.NewTextArea().
- SetPlaceholder("Type your prompt...")
- textArea.SetBorder(true).SetTitle("input")
- textView := tview.NewTextView().
- SetDynamicColors(true).
- SetRegions(true).
- SetChangedFunc(func() {
- app.Draw()
- })
- textView.SetBorder(true).SetTitle("chat")
- focusSwitcher[textArea] = textView
- focusSwitcher[textView] = textArea
- position := tview.NewTextView().
- SetDynamicColors(true).
- SetTextAlign(tview.AlignCenter)
- flex := tview.NewFlex().SetDirection(tview.FlexRow).
- AddItem(textView, 0, 40, false).
- AddItem(textArea, 0, 10, true).
- AddItem(position, 0, 1, false)
- updateStatusLine := func() {
- fromRow, fromColumn, toRow, toColumn := textArea.GetCursor()
- if fromRow == toRow && fromColumn == toColumn {
- position.SetText(fmt.Sprintf(indexLine, fromRow, fromColumn, botRespMode))
- } else {
- position.SetText(fmt.Sprintf("Esc: send msg; PgUp/Down: switch focus; F1: manage chats; F2: regen last; F3:delete last msg; F4: edit msg; F5: toggle system; F6: interrupt bot resp; Row: [yellow]%d[white], Column: [yellow]%d[white] - [red]To[white] Row: [yellow]%d[white], To Column: [yellow]%d; bot resp mode: %v", fromRow, fromColumn, toRow, toColumn, botRespMode))
- }
- }
- chatOpts := []string{"cancel", "new"}
- fList, err := loadHistoryChats()
- if err != nil {
- panic(err)
+ apiPort := flag.Int("port", 0, "port to host api")
+ flag.Parse()
+ if apiPort != nil && *apiPort > 3000 {
+ srv := Server{}
+ srv.ListenToRequests(strconv.Itoa(*apiPort))
+ return
}
- chatOpts = append(chatOpts, fList...)
- chatActModal := tview.NewModal().
- SetText("Chat actions:").
- AddButtons(chatOpts).
- SetDoneFunc(func(buttonIndex int, buttonLabel string) {
- switch buttonLabel {
- case "new":
- // set chat body
- chatBody.Messages = defaultStarter
- textView.SetText(chatToText(showSystemMsgs))
- activeChatName = path.Join(historyDir, fmt.Sprintf("%d_chat.json", time.Now().Unix()))
- pages.RemovePage("history")
- return
- // set text
- case "cancel":
- pages.RemovePage("history")
- return
- default:
- fn := buttonLabel
- history, err := loadHistoryChat(fn)
- if err != nil {
- logger.Error("failed to read history file", "filename", fn)
- pages.RemovePage("history")
- return
- }
- chatBody.Messages = history
- textView.SetText(chatToText(showSystemMsgs))
- activeChatName = fn
- pages.RemovePage("history")
- return
- }
- })
- editArea := tview.NewTextArea().
- SetPlaceholder("Replace msg...")
- editArea.SetBorder(true).SetTitle("input")
- editArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEscape && editMode {
- editedMsg := editArea.GetText()
- if editedMsg == "" {
- notifyUser("edit", "no edit provided")
- pages.RemovePage("editArea")
- editMode = false
- return nil
- }
- chatBody.Messages[selectedIndex].Content = editedMsg
- // change textarea
- textView.SetText(chatToText(showSystemMsgs))
- pages.RemovePage("editArea")
- editMode = false
- return nil
- }
- return event
- })
- indexPickWindow := tview.NewInputField().
- SetLabel("Enter a msg index: ").
- SetFieldWidth(4).
- SetAcceptanceFunc(tview.InputFieldInteger).
- SetDoneFunc(func(key tcell.Key) {
- pages.RemovePage("getIndex")
- return
- })
- indexPickWindow.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- si := indexPickWindow.GetText()
- selectedIndex, err = strconv.Atoi(si)
- if err != nil {
- logger.Error("failed to convert provided index", "error", err, "si", si)
- }
- if len(chatBody.Messages) <= selectedIndex && selectedIndex < 0 {
- logger.Warn("chosen index is out of bounds", "index", selectedIndex)
- return nil
- }
- m := chatBody.Messages[selectedIndex]
- if editMode && event.Key() == tcell.KeyEnter {
- pages.AddPage("editArea", editArea, true, true)
- editArea.SetText(m.Content, true)
- }
- if !editMode && event.Key() == tcell.KeyEnter {
- // TODO: add notification that text was copied
- copyToClipboard(m.Content)
- notification := fmt.Sprintf("msg '%s' was copied to the clipboard", m.Content[:30])
- notifyUser("copied", notification)
- }
- return event
- })
- //
- textArea.SetMovedFunc(updateStatusLine)
- updateStatusLine()
- textView.SetText(chatToText(showSystemMsgs))
- textView.ScrollToEnd()
- app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyF1 {
- // fList, err := listHistoryFiles(historyDir)
- fList, err := loadHistoryChats()
- if err != nil {
- panic(err)
- }
- chatOpts = append(chatOpts, fList...)
- pages.AddPage("history", chatActModal, true, true)
- return nil
- }
- if event.Key() == tcell.KeyF2 {
- // regen last msg
- chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
- textView.SetText(chatToText(showSystemMsgs))
- go chatRound("", userRole, textView)
- return nil
- }
- if event.Key() == tcell.KeyF3 {
- // delete last msg
- chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
- textView.SetText(chatToText(showSystemMsgs))
- botRespMode = false // hmmm; is that correct?
- return nil
- }
- if event.Key() == tcell.KeyF4 {
- // edit msg
- editMode = true
- pages.AddPage("getIndex", indexPickWindow, true, true)
- return nil
- }
- if event.Key() == tcell.KeyF5 {
- // switch showSystemMsgs
- showSystemMsgs = !showSystemMsgs
- textView.SetText(chatToText(showSystemMsgs))
- }
- if event.Key() == tcell.KeyF6 {
- interruptResp = true
- botRespMode = false
- return nil
- }
- if event.Key() == tcell.KeyF7 {
- // copy msg to clipboard
- editMode = false
- m := chatBody.Messages[len(chatBody.Messages)-1]
- copyToClipboard(m.Content)
- notification := fmt.Sprintf("msg '%s' was copied to the clipboard", m.Content[:30])
- notifyUser("copied", notification)
- return nil
- }
- if event.Key() == tcell.KeyF8 {
- // copy msg to clipboard
- editMode = false
- pages.AddPage("getIndex", indexPickWindow, true, true)
- return nil
- }
- // cannot send msg in editMode or botRespMode
- if event.Key() == tcell.KeyEscape && !editMode && !botRespMode {
- fromRow, fromColumn, _, _ := textArea.GetCursor()
- position.SetText(fmt.Sprintf(indexLine, fromRow, fromColumn, botRespMode))
- // read all text into buffer
- msgText := textArea.GetText()
- if msgText != "" {
- fmt.Fprintf(textView, "\n(%d) <user>: %s\n", len(chatBody.Messages), msgText)
- textArea.SetText("", true)
- textView.ScrollToEnd()
- }
- // update statue line
- go chatRound(msgText, userRole, textView)
- return nil
- }
- if event.Key() == tcell.KeyPgUp || event.Key() == tcell.KeyPgDn {
- currentF := app.GetFocus()
- app.SetFocus(focusSwitcher[currentF])
- return nil
- }
- if isASCII(string(event.Rune())) && !botRespMode {
- // botRespMode = false
- // fromRow, fromColumn, _, _ := textArea.GetCursor()
- // position.SetText(fmt.Sprintf(indexLine, fromRow, fromColumn, botRespMode))
- return event
- }
- return event
- })
pages.AddPage("main", flex, true, true)
if err := app.SetRoot(pages,
- true).EnableMouse(true).Run(); err != nil {
- panic(err)
+ true).EnableMouse(true).EnablePaste(true).Run(); err != nil {
+ logger.Error("failed to start tview app", "error", err)
+ return
}
}
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..fb0a774
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "gf-lt/models"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestRemoveThinking(t *testing.T) {
+ cases := []struct {
+ cb *models.ChatBody
+ toolMsgs uint8
+ }{
+ {cb: &models.ChatBody{
+ Stream: true,
+ Messages: []models.RoleMsg{
+ {Role: "tool", Content: "should be ommited"},
+ {Role: "system", Content: "should stay"},
+ {Role: "user", Content: "hello, how are you?"},
+ {Role: "assistant", Content: "Oh, hi. <think>I should thank user and continue the conversation</think> I am geat, thank you! How are you?"},
+ },
+ },
+ toolMsgs: uint8(1),
+ },
+ }
+ for i, tc := range cases {
+ t.Run(fmt.Sprintf("run_%d", i), func(t *testing.T) {
+ mNum := len(tc.cb.Messages)
+ removeThinking(tc.cb)
+ if len(tc.cb.Messages) != mNum-int(tc.toolMsgs) {
+ t.Error("failed to delete tools msg", tc.cb.Messages, cfg.ToolRole)
+ }
+ for _, msg := range tc.cb.Messages {
+ if strings.Contains(msg.Content, "<think>") {
+ t.Errorf("msg contains think tag; msg: %s\n", msg.Content)
+ }
+ }
+ })
+ }
+}
diff --git a/models/card.go b/models/card.go
new file mode 100644
index 0000000..adfb030
--- /dev/null
+++ b/models/card.go
@@ -0,0 +1,58 @@
+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"`
+ Extentions []byte `json:"extentions"`
+}
+
+type Spec2Wrapper struct {
+ Data CharCardSpec `json:"data"`
+}
+
+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 `json:"sys_prompt"`
+ FirstMsg string `json:"first_msg"`
+ 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/models/db.go b/models/db.go
index 5f49003..090f46d 100644
--- a/models/db.go
+++ b/models/db.go
@@ -8,13 +8,14 @@ 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
+ Agent string `db:"agent" json:"agent"`
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
}
@@ -34,3 +35,13 @@ type Memory struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
+
+// vector models
+
+type VectorRow struct {
+ Embeddings []float32 `db:"embeddings" json:"embeddings"`
+ Slug string `db:"slug" json:"slug"`
+ RawText string `db:"raw_text" json:"raw_text"`
+ Distance float32 `db:"distance" json:"distance"`
+ FileName string `db:"filename" json:"filename"`
+}
diff --git a/models/extra.go b/models/extra.go
new file mode 100644
index 0000000..e1ca80f
--- /dev/null
+++ b/models/extra.go
@@ -0,0 +1,8 @@
+package models
+
+type AudioFormat string
+
+const (
+ AFWav AudioFormat = "wav"
+ AFMP3 AudioFormat = "mp3"
+)
diff --git a/models/models.go b/models/models.go
index 880779f..918e35e 100644
--- a/models/models.go
+++ b/models/models.go
@@ -2,18 +2,13 @@ package models
import (
"fmt"
+ "gf-lt/config"
"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"`
+ Name string `json:"name"`
+ Args []string `json:"args"`
}
type LLMResp struct {
@@ -56,36 +51,55 @@ 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 {
- icon := ""
- switch m.Role {
- case "assistant":
- icon = fmt.Sprintf("(%d) <🤖>: ", i)
- case "user":
- icon = fmt.Sprintf("(%d) <user>: ", i)
- case "system":
- icon = fmt.Sprintf("(%d) <system>: ", i)
- case "tool":
- icon = fmt.Sprintf("(%d) <tool>: ", i)
+func (m RoleMsg) ToText(i int, cfg *config.Config) string {
+ icon := fmt.Sprintf("(%d)", i)
+ // check if already has role annotation (/completion makes them)
+ if !strings.HasPrefix(m.Content, m.Role+":") {
+ icon = fmt.Sprintf("(%d) <%s>: ", i, m.Role)
}
- textMsg := fmt.Sprintf("%s%s\n", icon, m.Content)
+ textMsg := fmt.Sprintf("[-:-:b]%s[-:-:-]\n%s\n", icon, m.Content)
return strings.ReplaceAll(textMsg, "\n\n", "\n")
}
+func (m RoleMsg) ToPrompt() string {
+ return strings.ReplaceAll(fmt.Sprintf("%s:\n%s", m.Role, m.Content), "\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"`
+}
+
+func (cb *ChatBody) Rename(oldname, newname string) {
+ for i, m := range cb.Messages {
+ cb.Messages[i].Content = strings.ReplaceAll(m.Content, oldname, newname)
+ cb.Messages[i].Role = strings.ReplaceAll(m.Role, oldname, newname)
+ }
+}
+
+func (cb *ChatBody) ListRoles() []string {
+ namesMap := make(map[string]struct{})
+ for _, m := range cb.Messages {
+ namesMap[m.Role] = struct{}{}
+ }
+ resp := make([]string, len(namesMap))
+ i := 0
+ for k := range namesMap {
+ resp[i] = k
+ i++
+ }
+ return resp
}
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 {
@@ -109,3 +123,238 @@ type ChatToolsBody struct {
} `json:"tools"`
ToolChoice string `json:"tool_choice"`
}
+
+type DSChatReq struct {
+ Messages []RoleMsg `json:"messages"`
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+ FrequencyPenalty int `json:"frequency_penalty"`
+ MaxTokens int `json:"max_tokens"`
+ PresencePenalty int `json:"presence_penalty"`
+ Temperature float32 `json:"temperature"`
+ TopP float32 `json:"top_p"`
+ // ResponseFormat struct {
+ // Type string `json:"type"`
+ // } `json:"response_format"`
+ // Stop any `json:"stop"`
+ // StreamOptions any `json:"stream_options"`
+ // Tools any `json:"tools"`
+ // ToolChoice string `json:"tool_choice"`
+ // Logprobs bool `json:"logprobs"`
+ // TopLogprobs any `json:"top_logprobs"`
+}
+
+func NewDSCharReq(cb ChatBody) DSChatReq {
+ return DSChatReq{
+ Messages: cb.Messages,
+ Model: cb.Model,
+ Stream: cb.Stream,
+ MaxTokens: 2048,
+ PresencePenalty: 0,
+ FrequencyPenalty: 0,
+ Temperature: 1.0,
+ TopP: 1.0,
+ }
+}
+
+type DSCompletionReq struct {
+ Model string `json:"model"`
+ Prompt string `json:"prompt"`
+ Echo bool `json:"echo"`
+ FrequencyPenalty int `json:"frequency_penalty"`
+ // Logprobs int `json:"logprobs"`
+ MaxTokens int `json:"max_tokens"`
+ PresencePenalty int `json:"presence_penalty"`
+ Stop any `json:"stop"`
+ Stream bool `json:"stream"`
+ StreamOptions any `json:"stream_options"`
+ Suffix any `json:"suffix"`
+ Temperature float32 `json:"temperature"`
+ TopP float32 `json:"top_p"`
+}
+
+func NewDSCompletionReq(prompt, model string, temp float32, cfg *config.Config) DSCompletionReq {
+ return DSCompletionReq{
+ Model: model,
+ Prompt: prompt,
+ Temperature: temp,
+ Stream: true,
+ Echo: false,
+ MaxTokens: 2048,
+ PresencePenalty: 0,
+ FrequencyPenalty: 0,
+ TopP: 1.0,
+ Stop: []string{
+ cfg.UserRole + ":\n", "<|im_end|>",
+ cfg.ToolRole + ":\n",
+ cfg.AssistantRole + ":\n",
+ },
+ }
+}
+
+type DSCompletionResp struct {
+ ID string `json:"id"`
+ Choices []struct {
+ FinishReason string `json:"finish_reason"`
+ Index int `json:"index"`
+ Logprobs struct {
+ TextOffset []int `json:"text_offset"`
+ TokenLogprobs []int `json:"token_logprobs"`
+ Tokens []string `json:"tokens"`
+ TopLogprobs []struct {
+ } `json:"top_logprobs"`
+ } `json:"logprobs"`
+ Text string `json:"text"`
+ } `json:"choices"`
+ Created int `json:"created"`
+ Model string `json:"model"`
+ SystemFingerprint string `json:"system_fingerprint"`
+ Object string `json:"object"`
+ Usage struct {
+ CompletionTokens int `json:"completion_tokens"`
+ PromptTokens int `json:"prompt_tokens"`
+ PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
+ PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ CompletionTokensDetails struct {
+ ReasoningTokens int `json:"reasoning_tokens"`
+ } `json:"completion_tokens_details"`
+ } `json:"usage"`
+}
+
+type DSChatResp struct {
+ Choices []struct {
+ Delta struct {
+ Content string `json:"content"`
+ Role any `json:"role"`
+ } `json:"delta"`
+ FinishReason string `json:"finish_reason"`
+ Index int `json:"index"`
+ Logprobs any `json:"logprobs"`
+ } `json:"choices"`
+ Created int `json:"created"`
+ ID string `json:"id"`
+ Model string `json:"model"`
+ Object string `json:"object"`
+ SystemFingerprint string `json:"system_fingerprint"`
+ Usage struct {
+ CompletionTokens int `json:"completion_tokens"`
+ PromptTokens int `json:"prompt_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ } `json:"usage"`
+}
+
+type DSChatStreamResp struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Created int `json:"created"`
+ Model string `json:"model"`
+ SystemFingerprint string `json:"system_fingerprint"`
+ Choices []struct {
+ Index int `json:"index"`
+ Delta struct {
+ Content string `json:"content"`
+ ReasoningContent string `json:"reasoning_content"`
+ } `json:"delta"`
+ Logprobs any `json:"logprobs"`
+ FinishReason string `json:"finish_reason"`
+ } `json:"choices"`
+}
+
+type EmbeddingResp struct {
+ Embedding []float32 `json:"embedding"`
+ Index uint32 `json:"index"`
+}
+
+// type EmbeddingsResp struct {
+// Model string `json:"model"`
+// Object string `json:"object"`
+// Usage struct {
+// PromptTokens int `json:"prompt_tokens"`
+// TotalTokens int `json:"total_tokens"`
+// } `json:"usage"`
+// Data []struct {
+// Embedding []float32 `json:"embedding"`
+// Index int `json:"index"`
+// Object string `json:"object"`
+// } `json:"data"`
+// }
+
+type LLMModels struct {
+ Object string `json:"object"`
+ Data []struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Created int `json:"created"`
+ OwnedBy string `json:"owned_by"`
+ Meta struct {
+ VocabType int `json:"vocab_type"`
+ NVocab int `json:"n_vocab"`
+ NCtxTrain int `json:"n_ctx_train"`
+ NEmbd int `json:"n_embd"`
+ NParams int64 `json:"n_params"`
+ Size int64 `json:"size"`
+ } `json:"meta"`
+ } `json:"data"`
+}
+
+type LlamaCPPReq struct {
+ Stream bool `json:"stream"`
+ // Messages []RoleMsg `json:"messages"`
+ Prompt string `json:"prompt"`
+ Temperature float32 `json:"temperature"`
+ DryMultiplier float32 `json:"dry_multiplier"`
+ Stop []string `json:"stop"`
+ MinP float32 `json:"min_p"`
+ NPredict int32 `json:"n_predict"`
+ // MaxTokens int `json:"max_tokens"`
+ // DryBase float64 `json:"dry_base"`
+ // DryAllowedLength int `json:"dry_allowed_length"`
+ // DryPenaltyLastN int `json:"dry_penalty_last_n"`
+ // CachePrompt bool `json:"cache_prompt"`
+ // DynatempRange int `json:"dynatemp_range"`
+ // DynatempExponent int `json:"dynatemp_exponent"`
+ // TopK int `json:"top_k"`
+ // TopP float32 `json:"top_p"`
+ // TypicalP int `json:"typical_p"`
+ // XtcProbability int `json:"xtc_probability"`
+ // XtcThreshold float32 `json:"xtc_threshold"`
+ // RepeatLastN int `json:"repeat_last_n"`
+ // RepeatPenalty int `json:"repeat_penalty"`
+ // PresencePenalty int `json:"presence_penalty"`
+ // FrequencyPenalty int `json:"frequency_penalty"`
+ // Samplers string `json:"samplers"`
+}
+
+func NewLCPReq(prompt string, cfg *config.Config, props map[string]float32) LlamaCPPReq {
+ return LlamaCPPReq{
+ Stream: true,
+ Prompt: prompt,
+ // Temperature: 0.8,
+ // DryMultiplier: 0.5,
+ Temperature: props["temperature"],
+ DryMultiplier: props["dry_multiplier"],
+ MinP: props["min_p"],
+ NPredict: int32(props["n_predict"]),
+ Stop: []string{
+ cfg.UserRole + ":\n", "<|im_end|>",
+ cfg.ToolRole + ":\n",
+ cfg.AssistantRole + ":\n",
+ },
+ }
+}
+
+type LlamaCPPResp struct {
+ Content string `json:"content"`
+ Stop bool `json:"stop"`
+}
+
+type DSBalance struct {
+ IsAvailable bool `json:"is_available"`
+ BalanceInfos []struct {
+ Currency string `json:"currency"`
+ TotalBalance string `json:"total_balance"`
+ GrantedBalance string `json:"granted_balance"`
+ ToppedUpBalance string `json:"topped_up_balance"`
+ } `json:"balance_infos"`
+}
diff --git a/pngmeta/altwriter.go b/pngmeta/altwriter.go
new file mode 100644
index 0000000..206b563
--- /dev/null
+++ b/pngmeta/altwriter.go
@@ -0,0 +1,133 @@
+package pngmeta
+
+import (
+ "bytes"
+ "gf-lt/models"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "hash/crc32"
+ "io"
+ "os"
+)
+
+const (
+ pngHeader = "\x89PNG\r\n\x1a\n"
+ textChunkType = "tEXt"
+)
+
+// WriteToPng embeds the metadata into the specified PNG file and writes the result to outfile.
+func WriteToPng(metadata *models.CharCardSpec, sourcePath, outfile string) error {
+ pngData, err := os.ReadFile(sourcePath)
+ if err != nil {
+ return err
+ }
+ jsonData, err := json.Marshal(metadata)
+ if err != nil {
+ return err
+ }
+ base64Data := base64.StdEncoding.EncodeToString(jsonData)
+ embedData := PngEmbed{
+ Key: "gf-lt", // Replace with appropriate key constant
+ Value: base64Data,
+ }
+ var outputBuffer bytes.Buffer
+ if _, err := outputBuffer.Write([]byte(pngHeader)); err != nil {
+ return err
+ }
+ chunks, iend, err := processChunks(pngData[8:])
+ if err != nil {
+ return err
+ }
+ for _, chunk := range chunks {
+ outputBuffer.Write(chunk)
+ }
+ newChunk, err := createTextChunk(embedData)
+ if err != nil {
+ return err
+ }
+ outputBuffer.Write(newChunk)
+ outputBuffer.Write(iend)
+ return os.WriteFile(outfile, outputBuffer.Bytes(), 0666)
+}
+
+// processChunks extracts non-tEXt chunks and locates the IEND chunk
+func processChunks(data []byte) ([][]byte, []byte, error) {
+ var (
+ chunks [][]byte
+ iendChunk []byte
+ reader = bytes.NewReader(data)
+ )
+ for {
+ var chunkLength uint32
+ if err := binary.Read(reader, binary.BigEndian, &chunkLength); err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ return nil, nil, fmt.Errorf("error reading chunk length: %w", err)
+ }
+ chunkType := make([]byte, 4)
+ if _, err := reader.Read(chunkType); err != nil {
+ return nil, nil, fmt.Errorf("error reading chunk type: %w", err)
+ }
+ chunkData := make([]byte, chunkLength)
+ if _, err := reader.Read(chunkData); err != nil {
+ return nil, nil, fmt.Errorf("error reading chunk data: %w", err)
+ }
+ crc := make([]byte, 4)
+ if _, err := reader.Read(crc); err != nil {
+ return nil, nil, fmt.Errorf("error reading CRC: %w", err)
+ }
+ fullChunk := bytes.NewBuffer(nil)
+ if err := binary.Write(fullChunk, binary.BigEndian, chunkLength); err != nil {
+ return nil, nil, fmt.Errorf("error writing chunk length: %w", err)
+ }
+ if _, err := fullChunk.Write(chunkType); err != nil {
+ return nil, nil, fmt.Errorf("error writing chunk type: %w", err)
+ }
+ if _, err := fullChunk.Write(chunkData); err != nil {
+ return nil, nil, fmt.Errorf("error writing chunk data: %w", err)
+ }
+ if _, err := fullChunk.Write(crc); err != nil {
+ return nil, nil, fmt.Errorf("error writing CRC: %w", err)
+ }
+ switch string(chunkType) {
+ case "IEND":
+ iendChunk = fullChunk.Bytes()
+ return chunks, iendChunk, nil
+ case textChunkType:
+ continue // Skip existing tEXt chunks
+ default:
+ chunks = append(chunks, fullChunk.Bytes())
+ }
+ }
+ return nil, nil, errors.New("IEND chunk not found")
+}
+
+// createTextChunk generates a valid tEXt chunk with proper CRC
+func createTextChunk(embed PngEmbed) ([]byte, error) {
+ content := bytes.NewBuffer(nil)
+ content.WriteString(embed.Key)
+ content.WriteByte(0) // Null separator
+ content.WriteString(embed.Value)
+ data := content.Bytes()
+ crc := crc32.NewIEEE()
+ crc.Write([]byte(textChunkType))
+ crc.Write(data)
+ chunk := bytes.NewBuffer(nil)
+ if err := binary.Write(chunk, binary.BigEndian, uint32(len(data))); err != nil {
+ return nil, fmt.Errorf("error writing chunk length: %w", err)
+ }
+ if _, err := chunk.Write([]byte(textChunkType)); err != nil {
+ return nil, fmt.Errorf("error writing chunk type: %w", err)
+ }
+ if _, err := chunk.Write(data); err != nil {
+ return nil, fmt.Errorf("error writing chunk data: %w", err)
+ }
+ if err := binary.Write(chunk, binary.BigEndian, crc.Sum32()); err != nil {
+ return nil, fmt.Errorf("error writing CRC: %w", err)
+ }
+ return chunk.Bytes(), nil
+}
diff --git a/pngmeta/metareader.go b/pngmeta/metareader.go
new file mode 100644
index 0000000..369345a
--- /dev/null
+++ b/pngmeta/metareader.go
@@ -0,0 +1,147 @@
+package pngmeta
+
+import (
+ "bytes"
+ "gf-lt/models"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "path"
+ "strings"
+)
+
+const (
+ embType = "tEXt"
+ cKey = "chara"
+ IEND = "IEND"
+ header = "\x89PNG\r\n\x1a\n"
+ writeHeader = "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"
+)
+
+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
+ }
+ specWrap := &models.Spec2Wrapper{}
+ if card.Name == "" {
+ if err := json.Unmarshal(data, &specWrap); err != nil {
+ return nil, err
+ }
+ return &specWrap.Data, nil
+ }
+ 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
+ }
+ if charSpec.Name == "" {
+ return nil, fmt.Errorf("failed to find role; fname %s", fname)
+ }
+ return charSpec.Simplify(uname, fname), nil
+}
+
+func readCardJson(fname string) (*models.CharCard, error) {
+ data, err := os.ReadFile(fname)
+ if err != nil {
+ return nil, err
+ }
+ card := models.CharCard{}
+ if err := json.Unmarshal(data, &card); err != nil {
+ return nil, err
+ }
+ return &card, nil
+}
+
+func ReadDirCards(dirname, uname string, log *slog.Logger) ([]*models.CharCard, error) {
+ files, err := os.ReadDir(dirname)
+ if err != nil {
+ return nil, err
+ }
+ resp := []*models.CharCard{}
+ for _, f := range files {
+ if f.IsDir() {
+ continue
+ }
+ if strings.HasSuffix(f.Name(), ".png") {
+ fpath := path.Join(dirname, f.Name())
+ cc, err := ReadCard(fpath, uname)
+ if err != nil {
+ log.Warn("failed to load card", "error", err)
+ continue
+ // return nil, err // better to log and continue
+ }
+ resp = append(resp, cc)
+ }
+ if strings.HasSuffix(f.Name(), ".json") {
+ fpath := path.Join(dirname, f.Name())
+ cc, err := readCardJson(fpath)
+ if err != nil {
+ return nil, err // better to log and continue
+ }
+ cc.FirstMsg = strings.ReplaceAll(strings.ReplaceAll(cc.FirstMsg, "{{char}}", cc.Role), "{{user}}", uname)
+ cc.SysPrompt = strings.ReplaceAll(strings.ReplaceAll(cc.SysPrompt, "{{char}}", cc.Role), "{{user}}", uname)
+ resp = append(resp, cc)
+ }
+ }
+ return resp, nil
+}
diff --git a/pngmeta/metareader_test.go b/pngmeta/metareader_test.go
new file mode 100644
index 0000000..f88de06
--- /dev/null
+++ b/pngmeta/metareader_test.go
@@ -0,0 +1,194 @@
+package pngmeta
+
+import (
+ "bytes"
+ "gf-lt/models"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestReadMeta(t *testing.T) {
+ cases := []struct {
+ Filename string
+ }{
+ {
+ 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", tc.Filename))
+ })
+ }
+}
+
+// Test helper: Create a simple PNG image with test shapes
+func createTestImage(t *testing.T) string {
+ img := image.NewRGBA(image.Rect(0, 0, 200, 200))
+ // Fill background with white
+ for y := 0; y < 200; y++ {
+ for x := 0; x < 200; x++ {
+ img.Set(x, y, color.White)
+ }
+ }
+ // Draw a red square
+ for y := 50; y < 150; y++ {
+ for x := 50; x < 150; x++ {
+ img.Set(x, y, color.RGBA{R: 255, A: 255})
+ }
+ }
+ // Draw a blue circle
+ center := image.Point{100, 100}
+ radius := 40
+ for y := center.Y - radius; y <= center.Y+radius; y++ {
+ for x := center.X - radius; x <= center.X+radius; x++ {
+ dx := x - center.X
+ dy := y - center.Y
+ if dx*dx+dy*dy <= radius*radius {
+ img.Set(x, y, color.RGBA{B: 255, A: 255})
+ }
+ }
+ }
+ // Create temp file
+ tmpDir := t.TempDir()
+ fpath := filepath.Join(tmpDir, "test-image.png")
+ f, err := os.Create(fpath)
+ if err != nil {
+ t.Fatalf("Error creating temp file: %v", err)
+ }
+ defer f.Close()
+ if err := png.Encode(f, img); err != nil {
+ t.Fatalf("Error encoding PNG: %v", err)
+ }
+ return fpath
+}
+
+func TestWriteToPng(t *testing.T) {
+ // Create test image
+ srcPath := createTestImage(t)
+ dstPath := filepath.Join(filepath.Dir(srcPath), "output.png")
+ // dstPath := "test.png"
+ // Create test metadata
+ metadata := &models.CharCardSpec{
+ Description: "Test image containing a red square and blue circle on white background",
+ }
+ // Embed metadata
+ if err := WriteToPng(metadata, srcPath, dstPath); err != nil {
+ t.Fatalf("WriteToPng failed: %v", err)
+ }
+ // Verify output file exists
+ if _, err := os.Stat(dstPath); os.IsNotExist(err) {
+ t.Fatalf("Output file not created: %v", err)
+ }
+ // Read and verify metadata
+ t.Run("VerifyMetadata", func(t *testing.T) {
+ data, err := os.ReadFile(dstPath)
+ if err != nil {
+ t.Fatalf("Error reading output file: %v", err)
+ }
+ // Verify PNG header
+ if string(data[:8]) != pngHeader {
+ t.Errorf("Invalid PNG header")
+ }
+ // Extract metadata
+ embedded := extractMetadata(t, data)
+ if embedded.Description != metadata.Description {
+ t.Errorf("Metadata mismatch\nWant: %q\nGot: %q",
+ metadata.Description, embedded.Description)
+ }
+ })
+ // Optional: Add cleanup if needed
+ // t.Cleanup(func() {
+ // os.Remove(dstPath)
+ // })
+}
+
+// Helper to extract embedded metadata from PNG bytes
+func extractMetadata(t *testing.T, data []byte) *models.CharCardSpec {
+ r := bytes.NewReader(data[8:]) // Skip PNG header
+ for {
+ var length uint32
+ if err := binary.Read(r, binary.BigEndian, &length); err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ t.Fatalf("Error reading chunk length: %v", err)
+ }
+ chunkType := make([]byte, 4)
+ if _, err := r.Read(chunkType); err != nil {
+ t.Fatalf("Error reading chunk type: %v", err)
+ }
+ // Read chunk data
+ chunkData := make([]byte, length)
+ if _, err := r.Read(chunkData); err != nil {
+ t.Fatalf("Error reading chunk data: %v", err)
+ }
+ // Read and discard CRC
+ if _, err := r.Read(make([]byte, 4)); err != nil {
+ t.Fatalf("Error reading CRC: %v", err)
+ }
+ if string(chunkType) == embType {
+ parts := bytes.SplitN(chunkData, []byte{0}, 2)
+ if len(parts) != 2 {
+ t.Fatalf("Invalid tEXt chunk format")
+ }
+ decoded, err := base64.StdEncoding.DecodeString(string(parts[1]))
+ if err != nil {
+ t.Fatalf("Base64 decode error: %v", err)
+ }
+ var result models.CharCardSpec
+ if err := json.Unmarshal(decoded, &result); err != nil {
+ t.Fatalf("JSON unmarshal error: %v", err)
+ }
+ return &result
+ }
+ }
+ t.Fatal("Metadata not found in PNG")
+ return nil
+}
+
+func readTextChunk(t *testing.T, r io.ReadSeeker) *models.CharCardSpec {
+ var length uint32
+ binary.Read(r, binary.BigEndian, &length)
+ chunkType := make([]byte, 4)
+ r.Read(chunkType)
+ data := make([]byte, length)
+ r.Read(data)
+ // Read CRC (but skip validation for test purposes)
+ crc := make([]byte, 4)
+ r.Read(crc)
+ parts := bytes.SplitN(data, []byte{0}, 2) // Split key-value pair
+ if len(parts) != 2 {
+ t.Fatalf("Invalid tEXt chunk format")
+ }
+ // key := string(parts[0])
+ value := parts[1]
+ decoded, err := base64.StdEncoding.DecodeString(string(value))
+ if err != nil {
+ t.Fatalf("Base64 decode error: %v; value: %s", err, string(value))
+ }
+ var result models.CharCardSpec
+ if err := json.Unmarshal(decoded, &result); err != nil {
+ t.Fatalf("JSON unmarshal error: %v", err)
+ }
+ return &result
+}
diff --git a/pngmeta/partsreader.go b/pngmeta/partsreader.go
new file mode 100644
index 0000000..d345a16
--- /dev/null
+++ b/pngmeta/partsreader.go
@@ -0,0 +1,75 @@
+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")
+)
+
+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
+}
diff --git a/pngmeta/partswriter.go b/pngmeta/partswriter.go
new file mode 100644
index 0000000..7282df6
--- /dev/null
+++ b/pngmeta/partswriter.go
@@ -0,0 +1,112 @@
+package pngmeta
+
+// import (
+// "bytes"
+// "encoding/binary"
+// "errors"
+// "fmt"
+// "hash/crc32"
+// "io"
+// )
+
+// 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 WWriteToPngriteToPng(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/rag/main.go b/rag/main.go
new file mode 100644
index 0000000..b7e0c00
--- /dev/null
+++ b/rag/main.go
@@ -0,0 +1,265 @@
+package rag
+
+import (
+ "bytes"
+ "gf-lt/config"
+ "gf-lt/models"
+ "gf-lt/storage"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "path"
+ "strings"
+ "sync"
+
+ "github.com/neurosnap/sentences/english"
+)
+
+var (
+ LongJobStatusCh = make(chan string, 1)
+ // messages
+ FinishedRAGStatus = "finished loading RAG file; press Enter"
+ LoadedFileRAGStatus = "loaded file"
+ ErrRAGStatus = "some error occured; failed to transfer data to vector db"
+)
+
+type RAG struct {
+ logger *slog.Logger
+ store storage.FullRepo
+ cfg *config.Config
+}
+
+func New(l *slog.Logger, s storage.FullRepo, cfg *config.Config) *RAG {
+ return &RAG{
+ logger: l,
+ store: s,
+ cfg: cfg,
+ }
+}
+
+func wordCounter(sentence string) int {
+ return len(strings.Split(sentence, " "))
+}
+
+func (r *RAG) LoadRAG(fpath string) error {
+ data, err := os.ReadFile(fpath)
+ if err != nil {
+ return err
+ }
+ r.logger.Debug("rag: loaded file", "fp", fpath)
+ LongJobStatusCh <- LoadedFileRAGStatus
+ fileText := string(data)
+ tokenizer, err := english.NewSentenceTokenizer(nil)
+ if err != nil {
+ return err
+ }
+ sentences := tokenizer.Tokenize(fileText)
+ sents := make([]string, len(sentences))
+ for i, s := range sentences {
+ sents[i] = s.Text
+ }
+ var (
+ maxChSize = 1000
+ left = 0
+ right = r.cfg.RAGBatchSize
+ batchCh = make(chan map[int][]string, maxChSize)
+ vectorCh = make(chan []models.VectorRow, maxChSize)
+ errCh = make(chan error, 1)
+ doneCh = make(chan bool, 1)
+ lock = new(sync.Mutex)
+ )
+ defer close(doneCh)
+ defer close(errCh)
+ defer close(batchCh)
+ // group sentences
+ paragraphs := []string{}
+ par := strings.Builder{}
+ for i := 0; i < len(sents); i++ {
+ par.WriteString(sents[i])
+ if wordCounter(par.String()) > int(r.cfg.RAGWordLimit) {
+ paragraphs = append(paragraphs, par.String())
+ par.Reset()
+ }
+ }
+ if len(paragraphs) < int(r.cfg.RAGBatchSize) {
+ r.cfg.RAGBatchSize = len(paragraphs)
+ }
+ // fill input channel
+ ctn := 0
+ for {
+ if int(right) > len(paragraphs) {
+ batchCh <- map[int][]string{left: paragraphs[left:]}
+ break
+ }
+ batchCh <- map[int][]string{left: paragraphs[left:right]}
+ left, right = right, right+r.cfg.RAGBatchSize
+ ctn++
+ }
+ finishedBatchesMsg := fmt.Sprintf("finished batching batches#: %d; paragraphs: %d; sentences: %d\n", len(batchCh), len(paragraphs), len(sents))
+ r.logger.Debug(finishedBatchesMsg)
+ LongJobStatusCh <- finishedBatchesMsg
+ for w := 0; w < int(r.cfg.RAGWorkers); w++ {
+ go r.batchToVectorHFAsync(lock, w, batchCh, vectorCh, errCh, doneCh, path.Base(fpath))
+ }
+ // wait for emb to be done
+ <-doneCh
+ // write to db
+ return r.writeVectors(vectorCh)
+}
+
+func (r *RAG) writeVectors(vectorCh chan []models.VectorRow) error {
+ for {
+ for batch := range vectorCh {
+ for _, vector := range batch {
+ if err := r.store.WriteVector(&vector); err != nil {
+ r.logger.Error("failed to write vector", "error", err, "slug", vector.Slug)
+ LongJobStatusCh <- ErrRAGStatus
+ continue // a duplicate is not critical
+ // return err
+ }
+ }
+ r.logger.Debug("wrote batch to db", "size", len(batch), "vector_chan_len", len(vectorCh))
+ if len(vectorCh) == 0 {
+ r.logger.Debug("finished writing vectors")
+ LongJobStatusCh <- FinishedRAGStatus
+ defer close(vectorCh)
+ return nil
+ }
+ }
+ }
+}
+
+func (r *RAG) batchToVectorHFAsync(lock *sync.Mutex, id int, inputCh <-chan map[int][]string,
+ vectorCh chan<- []models.VectorRow, errCh chan error, doneCh chan bool, filename string) {
+ for {
+ lock.Lock()
+ if len(inputCh) == 0 {
+ if len(doneCh) == 0 {
+ doneCh <- true
+ }
+ lock.Unlock()
+ return
+ }
+ select {
+ case linesMap := <-inputCh:
+ for leftI, v := range linesMap {
+ r.fecthEmbHF(v, errCh, vectorCh, fmt.Sprintf("%s_%d", filename, leftI), filename)
+ }
+ lock.Unlock()
+ case err := <-errCh:
+ r.logger.Error("got an error", "error", err)
+ lock.Unlock()
+ return
+ }
+ r.logger.Debug("to vector batches", "batches#", len(inputCh), "worker#", id)
+ LongJobStatusCh <- fmt.Sprintf("converted to vector; batches: %d, worker#: %d", len(inputCh), id)
+ }
+}
+
+func (r *RAG) fecthEmbHF(lines []string, errCh chan error, vectorCh chan<- []models.VectorRow, slug, filename string) {
+ payload, err := json.Marshal(
+ map[string]any{"inputs": lines, "options": map[string]bool{"wait_for_model": true}},
+ )
+ if err != nil {
+ r.logger.Error("failed to marshal payload", "err:", err.Error())
+ errCh <- err
+ return
+ }
+ // nolint
+ req, err := http.NewRequest("POST", r.cfg.EmbedURL, bytes.NewReader(payload))
+ if err != nil {
+ r.logger.Error("failed to create new req", "err:", err.Error())
+ errCh <- err
+ return
+ }
+ req.Header.Add("Authorization", "Bearer "+r.cfg.HFToken)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ r.logger.Error("failed to embedd line", "err:", err.Error())
+ errCh <- err
+ return
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ r.logger.Error("non 200 resp", "code", resp.StatusCode)
+ return
+ }
+ emb := [][]float32{}
+ if err := json.NewDecoder(resp.Body).Decode(&emb); err != nil {
+ r.logger.Error("failed to embedd line", "err:", err.Error())
+ errCh <- err
+ return
+ }
+ if len(emb) == 0 {
+ r.logger.Error("empty emb")
+ err = errors.New("empty emb")
+ errCh <- err
+ return
+ }
+ vectors := make([]models.VectorRow, len(emb))
+ for i, e := range emb {
+ vector := models.VectorRow{
+ Embeddings: e,
+ RawText: lines[i],
+ Slug: fmt.Sprintf("%s_%d", slug, i),
+ FileName: filename,
+ }
+ vectors[i] = vector
+ }
+ vectorCh <- vectors
+}
+
+func (r *RAG) LineToVector(line string) ([]float32, error) {
+ lines := []string{line}
+ payload, err := json.Marshal(
+ map[string]any{"inputs": lines, "options": map[string]bool{"wait_for_model": true}},
+ )
+ if err != nil {
+ r.logger.Error("failed to marshal payload", "err:", err.Error())
+ return nil, err
+ }
+ // nolint
+ req, err := http.NewRequest("POST", r.cfg.EmbedURL, bytes.NewReader(payload))
+ if err != nil {
+ r.logger.Error("failed to create new req", "err:", err.Error())
+ return nil, err
+ }
+ req.Header.Add("Authorization", "Bearer "+r.cfg.HFToken)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ r.logger.Error("failed to embedd line", "err:", err.Error())
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ err = fmt.Errorf("non 200 resp; code: %v", resp.StatusCode)
+ r.logger.Error(err.Error())
+ return nil, err
+ }
+ emb := [][]float32{}
+ if err := json.NewDecoder(resp.Body).Decode(&emb); err != nil {
+ r.logger.Error("failed to embedd line", "err:", err.Error())
+ return nil, err
+ }
+ if len(emb) == 0 || len(emb[0]) == 0 {
+ r.logger.Error("empty emb")
+ err = errors.New("empty emb")
+ return nil, err
+ }
+ return emb[0], nil
+}
+
+func (r *RAG) SearchEmb(emb *models.EmbeddingResp) ([]models.VectorRow, error) {
+ return r.store.SearchClosest(emb.Embedding)
+}
+
+func (r *RAG) ListLoaded() ([]string, error) {
+ return r.store.ListFiles()
+}
+
+func (r *RAG) RemoveFile(filename string) error {
+ return r.store.RemoveEmbByFileName(filename)
+}
diff --git a/server.go b/server.go
new file mode 100644
index 0000000..5654855
--- /dev/null
+++ b/server.go
@@ -0,0 +1,74 @@
+package main
+
+import (
+ "gf-lt/config"
+ "encoding/json"
+ "fmt"
+ "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 := fetchModelName()
+ 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)
+ }
+}
diff --git a/session.go b/session.go
index 23d725d..dbfa645 100644
--- a/session.go
+++ b/session.go
@@ -1,10 +1,13 @@
package main
import (
- "elefant/models"
+ "gf-lt/models"
"encoding/json"
+ "errors"
"fmt"
+ "os"
"os/exec"
+ "path/filepath"
"strings"
"time"
)
@@ -13,18 +16,44 @@ 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
}
if data == nil {
- return "", fmt.Errorf("nil data")
+ return "", errors.New("nil data")
}
return string(data), nil
}
-func updateStorageChat(name string, msgs []models.MessagesStory) error {
+func exportChat() error {
+ data, err := json.MarshalIndent(chatBody.Messages, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(activeChatName+".json", data, 0666)
+}
+
+func importChat(filename string) error {
+ data, err := os.ReadFile(filename)
+ if err != nil {
+ return err
+ }
+ messages := []models.RoleMsg{}
+ if err := json.Unmarshal(data, &messages); err != nil {
+ return err
+ }
+ activeChatName = filepath.Base(filename)
+ chatBody.Messages = messages
+ cfg.AssistantRole = messages[1].Role
+ if cfg.AssistantRole == cfg.UserRole {
+ cfg.AssistantRole = messages[2].Role
+ }
+ return nil
+}
+
+func updateStorageChat(name string, msgs []models.RoleMsg) error {
var err error
chat, ok := chatMap[name]
if !ok {
@@ -37,6 +66,7 @@ func updateStorageChat(name string, msgs []models.MessagesStory) error {
return err
}
chat.UpdatedAt = time.Now()
+ // if new chat will create id
_, err = store.UpsertChat(chat)
return err
}
@@ -46,56 +76,77 @@ func loadHistoryChats() ([]string, error) {
if err != nil {
return nil, err
}
- resp := []string{}
- for _, chat := range chats {
+ resp := make([]string, len(chats))
+ for i, chat := range chats {
if chat.Name == "" {
- chat.Name = fmt.Sprintf("%d_%v", chat.ID, chat.CreatedAt.Unix())
+ chat.Name = fmt.Sprintf("%d_%v", chat.ID, chat.Agent)
}
- resp = append(resp, chat.Name)
+ resp[i] = chat.Name
chatMap[chat.Name] = &chat
}
return resp, nil
}
-func loadHistoryChat(chatName string) ([]models.MessagesStory, error) {
+func loadHistoryChat(chatName string) ([]models.RoleMsg, error) {
chat, ok := chatMap[chatName]
if !ok {
- err := fmt.Errorf("failed to read chat")
+ err := errors.New("failed to read chat")
logger.Error("failed to read chat", "name", chatName)
return nil, err
}
activeChatName = chatName
+ cfg.AssistantRole = chat.Agent
return chat.ToHistory()
}
-func loadOldChatOrGetNew() []models.MessagesStory {
- newChat := &models.Chat{
- ID: 0,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
+func loadAgentsLastChat(agent string) ([]models.RoleMsg, error) {
+ chat, err := store.GetLastChatByAgent(agent)
+ if err != nil {
+ return nil, err
}
- newChat.Name = fmt.Sprintf("%d_%v", newChat.ID, newChat.CreatedAt.Unix())
+ history, err := chat.ToHistory()
+ if err != nil {
+ return nil, err
+ }
+ if chat.Name == "" {
+ logger.Warn("empty chat name", "id", chat.ID)
+ chat.Name = fmt.Sprintf("%s_%d", chat.Agent, chat.ID)
+ }
+ chatMap[chat.Name] = chat
+ activeChatName = chat.Name
+ return history, nil
+}
+
+func loadOldChatOrGetNew() []models.RoleMsg {
// find last chat
chat, err := store.GetLastChat()
if err != nil {
logger.Warn("failed to load history chat", "error", err)
- activeChatName = newChat.Name
- chatMap[newChat.Name] = newChat
+ chat := &models.Chat{
+ ID: 0,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ Agent: cfg.AssistantRole,
+ }
+ chat.Name = fmt.Sprintf("%s_%v", chat.Agent, chat.CreatedAt.Unix())
+ activeChatName = chat.Name
+ chatMap[chat.Name] = chat
return defaultStarter
}
history, err := chat.ToHistory()
if err != nil {
logger.Warn("failed to load history chat", "error", err)
- activeChatName = newChat.Name
- chatMap[newChat.Name] = newChat
+ activeChatName = chat.Name
+ chatMap[chat.Name] = chat
return defaultStarter
}
- if chat.Name == "" {
- logger.Warn("empty chat name", "id", chat.ID)
- chat.Name = fmt.Sprintf("%d_%v", chat.ID, chat.CreatedAt.Unix())
- }
+ // if chat.Name == "" {
+ // logger.Warn("empty chat name", "id", chat.ID)
+ // chat.Name = fmt.Sprintf("%s_%v", chat.Agent, chat.CreatedAt.Unix())
+ // }
chatMap[chat.Name] = chat
activeChatName = chat.Name
+ cfg.AssistantRole = chat.Agent
return history
}
diff --git a/storage/memory.go b/storage/memory.go
index a7bf8cc..406182f 100644
--- a/storage/memory.go
+++ b/storage/memory.go
@@ -1,6 +1,6 @@
package storage
-import "elefant/models"
+import "gf-lt/models"
type Memories interface {
Memorise(m *models.Memory) (*models.Memory, error)
@@ -9,15 +9,23 @@ type Memories interface {
}
func (p ProviderSQL) Memorise(m *models.Memory) (*models.Memory, error) {
- query := "INSERT INTO memories (agent, topic, mind) VALUES (:agent, :topic, :mind) RETURNING *;"
+ query := `
+ INSERT INTO memories (agent, topic, mind)
+ VALUES (:agent, :topic, :mind)
+ ON CONFLICT (agent, topic) DO UPDATE
+ SET mind = excluded.mind,
+ updated_at = CURRENT_TIMESTAMP
+ RETURNING *;`
stmt, err := p.db.PrepareNamed(query)
if err != nil {
+ p.logger.Error("failed to prepare stmt", "query", query, "error", err)
return nil, err
}
defer stmt.Close()
var memory models.Memory
err = stmt.Get(&memory, m)
if err != nil {
+ p.logger.Error("failed to upsert memory", "query", query, "error", err)
return nil, err
}
return &memory, nil
@@ -28,6 +36,7 @@ func (p ProviderSQL) Recall(agent, topic string) (string, error) {
var mind string
err := p.db.Get(&mind, query, agent, topic)
if err != nil {
+ p.logger.Error("failed to get memory", "query", query, "error", err)
return "", err
}
return mind, nil
@@ -38,6 +47,7 @@ func (p ProviderSQL) RecallTopics(agent string) ([]string, error) {
var topics []string
err := p.db.Select(&topics, query, agent)
if err != nil {
+ p.logger.Error("failed to get topics", "query", query, "error", err)
return nil, err
}
return topics, nil
diff --git a/storage/migrate.go b/storage/migrate.go
index d97b99d..b05dddc 100644
--- a/storage/migrate.go
+++ b/storage/migrate.go
@@ -5,6 +5,8 @@ import (
"fmt"
"io/fs"
"strings"
+
+ _ "github.com/asg017/sqlite-vec-go-bindings/ncruces"
)
//go:embed migrations/*
@@ -27,10 +29,11 @@ func (p *ProviderSQL) Migrate() {
err := p.executeMigration(migrationsDir, file.Name())
if err != nil {
p.logger.Error("Failed to execute migration %s: %v", file.Name(), err)
+ panic(err)
}
}
}
- p.logger.Info("All migrations executed successfully!")
+ p.logger.Debug("All migrations executed successfully!")
}
func (p *ProviderSQL) executeMigration(migrationsDir fs.FS, fileName string) error {
@@ -51,7 +54,7 @@ func (p *ProviderSQL) executeMigration(migrationsDir fs.FS, fileName string) err
func (p *ProviderSQL) executeSQL(sqlContent []byte) error {
// Connect to the database (example using a simple connection)
- _, err := p.db.Exec(string(sqlContent))
+ err := p.s3Conn.Exec(string(sqlContent))
if err != nil {
return fmt.Errorf("failed to execute SQL: %w", err)
}
diff --git a/storage/migrations/001_init.up.sql b/storage/migrations/001_init.up.sql
index 8980ccf..09bb5e6 100644
--- a/storage/migrations/001_init.up.sql
+++ b/storage/migrations/001_init.up.sql
@@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
msgs TEXT NOT NULL,
+ agent TEXT NOT NULL DEFAULT 'assistant',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
diff --git a/storage/migrations/002_add_vector.up.sql b/storage/migrations/002_add_vector.up.sql
new file mode 100644
index 0000000..2ac4621
--- /dev/null
+++ b/storage/migrations/002_add_vector.up.sql
@@ -0,0 +1,12 @@
+--CREATE VIRTUAL TABLE IF NOT EXISTS embeddings_5120 USING vec0(
+-- embedding FLOAT[5120],
+-- slug TEXT NOT NULL,
+-- raw_text TEXT PRIMARY KEY,
+--);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS embeddings_384 USING vec0(
+ embedding FLOAT[384],
+ slug TEXT NOT NULL,
+ raw_text TEXT PRIMARY KEY,
+ filename TEXT NOT NULL DEFAULT ''
+);
diff --git a/storage/storage.go b/storage/storage.go
index 67b8dd8..7911e13 100644
--- a/storage/storage.go
+++ b/storage/storage.go
@@ -1,23 +1,34 @@
package storage
import (
- "elefant/models"
+ "gf-lt/models"
"log/slog"
_ "github.com/glebarez/go-sqlite"
"github.com/jmoiron/sqlx"
+ "github.com/ncruces/go-sqlite3"
)
+type FullRepo interface {
+ ChatHistory
+ Memories
+ VectorRepo
+}
+
type ChatHistory interface {
ListChats() ([]models.Chat, error)
GetChatByID(id uint32) (*models.Chat, error)
+ GetChatByChar(char string) ([]models.Chat, error)
GetLastChat() (*models.Chat, error)
+ GetLastChatByAgent(agent string) (*models.Chat, error)
UpsertChat(chat *models.Chat) (*models.Chat, error)
RemoveChat(id uint32) error
+ ChatGetMaxID() (uint32, error)
}
type ProviderSQL struct {
db *sqlx.DB
+ s3Conn *sqlite3.Conn
logger *slog.Logger
}
@@ -27,6 +38,12 @@ func (p ProviderSQL) ListChats() ([]models.Chat, error) {
return resp, err
}
+func (p ProviderSQL) GetChatByChar(char string) ([]models.Chat, error) {
+ resp := []models.Chat{}
+ err := p.db.Select(&resp, "SELECT * FROM chats WHERE agent=$1;", char)
+ return resp, err
+}
+
func (p ProviderSQL) GetChatByID(id uint32) (*models.Chat, error) {
resp := models.Chat{}
err := p.db.Get(&resp, "SELECT * FROM chats WHERE id=$1;", id)
@@ -39,16 +56,28 @@ func (p ProviderSQL) GetLastChat() (*models.Chat, error) {
return &resp, err
}
+func (p ProviderSQL) GetLastChatByAgent(agent string) (*models.Chat, error) {
+ resp := models.Chat{}
+ query := "SELECT * FROM chats WHERE agent=$1 ORDER BY updated_at DESC LIMIT 1"
+ err := p.db.Get(&resp, query, agent)
+ return &resp, err
+}
+
+// https://sqlite.org/lang_upsert.html
+// on conflict was added
func (p ProviderSQL) UpsertChat(chat *models.Chat) (*models.Chat, error) {
// Prepare the SQL statement
query := `
- INSERT OR REPLACE INTO chats (id, name, msgs, created_at, updated_at)
- VALUES (:id, :name, :msgs, :created_at, :updated_at)
+ INSERT INTO chats (id, name, msgs, agent, created_at, updated_at)
+ VALUES (:id, :name, :msgs, :agent, :created_at, :updated_at)
+ ON CONFLICT(id) DO UPDATE SET msgs=excluded.msgs,
+ updated_at=excluded.updated_at
RETURNING *;`
stmt, err := p.db.PrepareNamed(query)
if err != nil {
return nil, err
}
+ defer stmt.Close()
// Execute the query and scan the result into a new chat object
var resp models.Chat
err = stmt.Get(&resp, chat)
@@ -61,13 +90,26 @@ func (p ProviderSQL) RemoveChat(id uint32) error {
return err
}
-func NewProviderSQL(dbPath string, logger *slog.Logger) ChatHistory {
+func (p ProviderSQL) ChatGetMaxID() (uint32, error) {
+ query := "SELECT MAX(id) FROM chats;"
+ var id uint32
+ err := p.db.Get(&id, query)
+ return id, err
+}
+
+// opens two connections
+func NewProviderSQL(dbPath string, logger *slog.Logger) FullRepo {
db, err := sqlx.Open("sqlite", dbPath)
if err != nil {
- panic(err)
+ logger.Error("failed to open db connection", "error", err)
+ return nil
}
- // get SQLite version
p := ProviderSQL{db: db, logger: logger}
+ p.s3Conn, err = sqlite3.Open(dbPath)
+ if err != nil {
+ logger.Error("failed to open vecdb connection", "error", err)
+ return nil
+ }
p.Migrate()
return p
}
diff --git a/storage/storage_test.go b/storage/storage_test.go
index ad1f1bf..a1c4cf4 100644
--- a/storage/storage_test.go
+++ b/storage/storage_test.go
@@ -1,7 +1,7 @@
package storage
import (
- "elefant/models"
+ "gf-lt/models"
"fmt"
"log/slog"
"os"
@@ -35,22 +35,27 @@ CREATE TABLE IF NOT EXISTS memories (
logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)),
}
// Create a sample memory for testing
- sampleMemory := &models.Memory{
+ sampleMemory := models.Memory{
Agent: "testAgent",
Topic: "testTopic",
Mind: "testMind",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
+ sampleMemoryRewrite := models.Memory{
+ Agent: "testAgent",
+ Topic: "testTopic",
+ Mind: "same topic, new mind",
+ }
cases := []struct {
- memory *models.Memory
+ memories []models.Memory
}{
- {memory: sampleMemory},
+ {memories: []models.Memory{sampleMemory, sampleMemoryRewrite}},
}
for i, tc := range cases {
t.Run(fmt.Sprintf("run_%d", i), func(t *testing.T) {
// Recall topics: get no rows
- topics, err := provider.RecallTopics(tc.memory.Agent)
+ topics, err := provider.RecallTopics(tc.memories[0].Agent)
if err != nil {
t.Fatalf("Failed to recall topics: %v", err)
}
@@ -58,12 +63,12 @@ CREATE TABLE IF NOT EXISTS memories (
t.Fatalf("Expected no topics, got: %v", topics)
}
// Memorise
- _, err = provider.Memorise(tc.memory)
+ _, err = provider.Memorise(&tc.memories[0])
if err != nil {
t.Fatalf("Failed to memorise: %v", err)
}
// Recall topics: has topics
- topics, err = provider.RecallTopics(tc.memory.Agent)
+ topics, err = provider.RecallTopics(tc.memories[0].Agent)
if err != nil {
t.Fatalf("Failed to recall topics: %v", err)
}
@@ -71,12 +76,20 @@ CREATE TABLE IF NOT EXISTS memories (
t.Fatalf("Expected topics, got none")
}
// Recall
- content, err := provider.Recall(tc.memory.Agent, tc.memory.Topic)
+ content, err := provider.Recall(tc.memories[0].Agent, tc.memories[0].Topic)
if err != nil {
t.Fatalf("Failed to recall: %v", err)
}
- if content != tc.memory.Mind {
- t.Fatalf("Expected content: %v, got: %v", tc.memory.Mind, content)
+ if content != tc.memories[0].Mind {
+ t.Fatalf("Expected content: %v, got: %v", tc.memories[0].Mind, content)
+ }
+ // rewrite mind of same agent-topic
+ newMem, err := provider.Memorise(&tc.memories[1])
+ if err != nil {
+ t.Fatalf("Failed to memorise: %v", err)
+ }
+ if newMem.Mind == tc.memories[0].Mind {
+ t.Fatalf("Failed to change mind: %v", newMem.Mind)
}
})
}
@@ -95,6 +108,7 @@ func TestChatHistory(t *testing.T) {
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
msgs TEXT NOT NULL,
+ agent TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);`)
@@ -159,3 +173,88 @@ func TestChatHistory(t *testing.T) {
t.Errorf("Expected 0 chats, got %d", len(chats))
}
}
+
+// func TestVecTable(t *testing.T) {
+// // healthcheck
+// db, err := sqlite3.Open(":memory:")
+// if err != nil {
+// t.Fatal(err)
+// }
+// stmt, _, err := db.Prepare(`SELECT sqlite_version(), vec_version()`)
+// if err != nil {
+// t.Fatal(err)
+// }
+// stmt.Step()
+// log.Printf("sqlite_version=%s, vec_version=%s\n", stmt.ColumnText(0), stmt.ColumnText(1))
+// stmt.Close()
+// // migration
+// err = db.Exec("CREATE VIRTUAL TABLE vec_items USING vec0(embedding float[4], chat_name TEXT NOT NULL)")
+// if err != nil {
+// t.Fatal(err)
+// }
+// // data prep and insert
+// items := map[int][]float32{
+// 1: {0.1, 0.1, 0.1, 0.1},
+// 2: {0.2, 0.2, 0.2, 0.2},
+// 3: {0.3, 0.3, 0.3, 0.3},
+// 4: {0.4, 0.4, 0.4, 0.4},
+// 5: {0.5, 0.5, 0.5, 0.5},
+// }
+// q := []float32{0.4, 0.3, 0.3, 0.3}
+// stmt, _, err = db.Prepare("INSERT INTO vec_items(rowid, embedding, chat_name) VALUES (?, ?, ?)")
+// if err != nil {
+// t.Fatal(err)
+// }
+// for id, values := range items {
+// v, err := sqlite_vec.SerializeFloat32(values)
+// if err != nil {
+// t.Fatal(err)
+// }
+// stmt.BindInt(1, id)
+// stmt.BindBlob(2, v)
+// stmt.BindText(3, "some_chat")
+// err = stmt.Exec()
+// if err != nil {
+// t.Fatal(err)
+// }
+// stmt.Reset()
+// }
+// stmt.Close()
+// // select | vec search
+// stmt, _, err = db.Prepare(`
+// SELECT
+// rowid,
+// distance,
+// embedding
+// FROM vec_items
+// WHERE embedding MATCH ?
+// ORDER BY distance
+// LIMIT 3
+// `)
+// if err != nil {
+// t.Fatal(err)
+// }
+// query, err := sqlite_vec.SerializeFloat32(q)
+// if err != nil {
+// t.Fatal(err)
+// }
+// stmt.BindBlob(1, query)
+// for stmt.Step() {
+// rowid := stmt.ColumnInt64(0)
+// distance := stmt.ColumnFloat(1)
+// emb := stmt.ColumnRawText(2)
+// floats := decodeUnsafe(emb)
+// log.Printf("rowid=%d, distance=%f, floats=%v\n", rowid, distance, floats)
+// }
+// if err := stmt.Err(); err != nil {
+// t.Fatal(err)
+// }
+// err = stmt.Close()
+// if err != nil {
+// t.Fatal(err)
+// }
+// err = db.Close()
+// if err != nil {
+// t.Fatal(err)
+// }
+// }
diff --git a/storage/vector.go b/storage/vector.go
new file mode 100644
index 0000000..71005e4
--- /dev/null
+++ b/storage/vector.go
@@ -0,0 +1,163 @@
+package storage
+
+import (
+ "gf-lt/models"
+ "errors"
+ "fmt"
+ "unsafe"
+
+ sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/ncruces"
+)
+
+type VectorRepo interface {
+ WriteVector(*models.VectorRow) error
+ SearchClosest(q []float32) ([]models.VectorRow, error)
+ ListFiles() ([]string, error)
+ RemoveEmbByFileName(filename string) error
+}
+
+var (
+ vecTableName5120 = "embeddings_5120"
+ vecTableName384 = "embeddings_384"
+)
+
+func fetchTableName(emb []float32) (string, error) {
+ switch len(emb) {
+ case 5120:
+ return vecTableName5120, nil
+ case 384:
+ return vecTableName384, nil
+ default:
+ return "", fmt.Errorf("no table for the size of %d", len(emb))
+ }
+}
+
+func (p ProviderSQL) WriteVector(row *models.VectorRow) error {
+ tableName, err := fetchTableName(row.Embeddings)
+ if err != nil {
+ return err
+ }
+ stmt, _, err := p.s3Conn.Prepare(
+ fmt.Sprintf("INSERT INTO %s(embedding, slug, raw_text, filename) VALUES (?, ?, ?, ?)", tableName))
+ if err != nil {
+ p.logger.Error("failed to prep a stmt", "error", err)
+ return err
+ }
+ defer stmt.Close()
+ v, err := sqlite_vec.SerializeFloat32(row.Embeddings)
+ if err != nil {
+ p.logger.Error("failed to serialize vector",
+ "emb-len", len(row.Embeddings), "error", err)
+ return err
+ }
+ if v == nil {
+ err = errors.New("empty vector after serialization")
+ p.logger.Error("empty vector after serialization",
+ "emb-len", len(row.Embeddings), "text", row.RawText, "error", err)
+ return err
+ }
+ if err := stmt.BindBlob(1, v); err != nil {
+ p.logger.Error("failed to bind", "error", err)
+ return err
+ }
+ if err := stmt.BindText(2, row.Slug); err != nil {
+ p.logger.Error("failed to bind", "error", err)
+ return err
+ }
+ if err := stmt.BindText(3, row.RawText); err != nil {
+ p.logger.Error("failed to bind", "error", err)
+ return err
+ }
+ if err := stmt.BindText(4, row.FileName); err != nil {
+ p.logger.Error("failed to bind", "error", err)
+ return err
+ }
+ err = stmt.Exec()
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func decodeUnsafe(bs []byte) []float32 {
+ return unsafe.Slice((*float32)(unsafe.Pointer(&bs[0])), len(bs)/4)
+}
+
+func (p ProviderSQL) SearchClosest(q []float32) ([]models.VectorRow, error) {
+ tableName, err := fetchTableName(q)
+ if err != nil {
+ return nil, err
+ }
+ stmt, _, err := p.s3Conn.Prepare(
+ fmt.Sprintf(`SELECT
+ distance,
+ embedding,
+ slug,
+ raw_text,
+ filename
+ FROM %s
+ WHERE embedding MATCH ?
+ ORDER BY distance
+ LIMIT 3
+ `, tableName))
+ if err != nil {
+ return nil, err
+ }
+ query, err := sqlite_vec.SerializeFloat32(q[:])
+ if err != nil {
+ return nil, err
+ }
+ if err := stmt.BindBlob(1, query); err != nil {
+ p.logger.Error("failed to bind", "error", err)
+ return nil, err
+ }
+ resp := []models.VectorRow{}
+ for stmt.Step() {
+ res := models.VectorRow{}
+ res.Distance = float32(stmt.ColumnFloat(0))
+ emb := stmt.ColumnRawText(1)
+ res.Embeddings = decodeUnsafe(emb)
+ res.Slug = stmt.ColumnText(2)
+ res.RawText = stmt.ColumnText(3)
+ res.FileName = stmt.ColumnText(4)
+ resp = append(resp, res)
+ }
+ if err := stmt.Err(); err != nil {
+ return nil, err
+ }
+ err = stmt.Close()
+ if err != nil {
+ return nil, err
+ }
+ return resp, nil
+}
+
+func (p ProviderSQL) ListFiles() ([]string, error) {
+ q := fmt.Sprintf("SELECT filename FROM %s GROUP BY filename", vecTableName384)
+ stmt, _, err := p.s3Conn.Prepare(q)
+ if err != nil {
+ return nil, err
+ }
+ defer stmt.Close()
+ resp := []string{}
+ for stmt.Step() {
+ resp = append(resp, stmt.ColumnText(0))
+ }
+ if err := stmt.Err(); err != nil {
+ return nil, err
+ }
+ return resp, nil
+}
+
+func (p ProviderSQL) RemoveEmbByFileName(filename string) error {
+ q := fmt.Sprintf("DELETE FROM %s WHERE filename = ?", vecTableName384)
+ stmt, _, err := p.s3Conn.Prepare(q)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+ if err := stmt.BindText(1, filename); err != nil {
+ return err
+ }
+ return stmt.Exec()
+}
diff --git a/sysprompts/cluedo.json b/sysprompts/cluedo.json
new file mode 100644
index 0000000..0c90cb5
--- /dev/null
+++ b/sysprompts/cluedo.json
@@ -0,0 +1,7 @@
+{
+ "sys_prompt": "A game of cluedo. Players are {{user}}, {{char}}, {{char2}};\n\nrooms: hall, lounge, dinning room kitchen, ballroom, conservatory, billiard room, library, study;\nweapons: candlestick, dagger, lead pipe, revolver, rope, spanner;\npeople: miss Scarlett, colonel Mustard, mrs. White, reverend Green, mrs. Peacock, professor Plum;\n\nA murder happened in a mansion with 9 rooms. Victim is dr. Black.\nPlayers goal is to find out who commited a murder, in what room and with what weapon.\nWeapons, people and rooms not involved in murder are distributed between players (as cards) by tool agent.\nThe objective of the game is to deduce the details of the murder. There are six characters, six murder weapons, and nine rooms, leaving the players with 324 possibilities. As soon as a player enters a room, they may make a suggestion as to the details, naming a suspect, the room they are in, and the weapon. For example: \"I suspect Professor Plum, in the Dining Room, with the candlestick\".\nOnce a player makes a suggestion, the others are called upon to disprove it.\nBefore the player's move, tool agent will remind that players their cards. There are two types of moves: making a suggestion (suggestion_move) and disproving other player suggestion (evidence_move);\nIn this version player wins when the correct details are named in the suggestion_move.\n\n<example_game>\n{{user}}:\nlet's start a game of cluedo!\ntool: cards of {{char}} are 'LEAD PIPE', 'BALLROOM', 'CONSERVATORY', 'STUDY', 'Mrs. White'; suggestion_move;\n{{char}}:\n(putting miss Scarlet into the Hall with the Revolver) \"I suspect miss Scarlett, in the Hall, with the revolver.\"\ntool: cards of {{char2}} are 'SPANNER', 'DAGGER', 'Professor Plum', 'LIBRARY', 'Mrs. Peacock'; evidence_move;\n{{char2}}:\n\"No objections.\" (no cards matching the suspicion of {{char}})\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; evidence_move;\n{{user}}:\n\"I object. Miss Scarlett is innocent.\" (shows card with 'Miss Scarlett')\ntool: cards of {{char2}} are 'SPANNER', 'DAGGER', 'Professor Plum', 'LIBRARY', 'Mrs. Peacock'; suggestion_move;\n{{char2}}:\n*So it was not Miss Scarlett, good to know.*\n(moves Mrs. White to the Billiard Room) \"It might have been Mrs. White, in the Billiard Room, with the Revolver.\"\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; evidence_move;\n{{user}}:\n(no matching cards for the assumption of {{char2}}) \"Sounds possible to me.\"\ntool: cards of {{char}} are 'LEAD PIPE', 'BALLROOM', 'CONSERVATORY', 'STUDY', 'Mrs. White'; evidence_move;\n{{char}}:\n(shows Mrs. White card) \"No. Was not Mrs. White\"\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; suggestion_move;\n{{user}}:\n*So not Mrs. White...* (moves Reverend Green into the Billiard Room) \"I suspect Reverend Green, in the Billiard Room, with the Revolver.\"\ntool: Correct. It was Reverend Green in the Billiard Room, with the revolver. {{user}} wins.\n</example_game>",
+ "role": "CluedoPlayer",
+ "role2": "CluedoEnjoyer",
+ "filepath": "sysprompts/cluedo.json",
+ "first_msg": "Hey guys! Want to play cluedo?"
+}
diff --git a/sysprompts/llama.png b/sysprompts/llama.png
new file mode 100644
index 0000000..7317300
--- /dev/null
+++ b/sysprompts/llama.png
Binary files differ
diff --git a/tables.go b/tables.go
new file mode 100644
index 0000000..c4c97b9
--- /dev/null
+++ b/tables.go
@@ -0,0 +1,535 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "strings"
+ "time"
+
+ "gf-lt/models"
+ "gf-lt/pngmeta"
+ "gf-lt/rag"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
+ actions := []string{"load", "rename", "delete", "update card", "move sysprompt onto 1st msg", "new_chat_from_card"}
+ chatList := make([]string, len(chatMap))
+ i := 0
+ for name := range chatMap {
+ chatList[i] = name
+ i++
+ }
+ rows, cols := len(chatMap), len(actions)+2
+ chatActTable := tview.NewTable().
+ SetBorders(true)
+ for r := 0; r < rows; r++ {
+ for c := 0; c < cols; c++ {
+ color := tcell.ColorWhite
+ switch c {
+ case 0:
+ chatActTable.SetCell(r, c,
+ tview.NewTableCell(chatList[r]).
+ SetTextColor(color).
+ SetAlign(tview.AlignCenter))
+ case 1:
+ chatActTable.SetCell(r, c,
+ tview.NewTableCell(chatMap[chatList[r]].Msgs[len(chatMap[chatList[r]].Msgs)-30:]).
+ SetTextColor(color).
+ SetAlign(tview.AlignCenter))
+ default:
+ chatActTable.SetCell(r, c,
+ tview.NewTableCell(actions[c-2]).
+ SetTextColor(color).
+ SetAlign(tview.AlignCenter))
+ }
+ }
+ }
+ chatActTable.Select(0, 0).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) {
+ if key == tcell.KeyEsc || key == tcell.KeyF1 {
+ pages.RemovePage(historyPage)
+ return
+ }
+ if key == tcell.KeyEnter {
+ chatActTable.SetSelectable(true, true)
+ }
+ }).SetSelectedFunc(func(row int, column int) {
+ tc := chatActTable.GetCell(row, column)
+ tc.SetTextColor(tcell.ColorRed)
+ chatActTable.SetSelectable(false, false)
+ selectedChat := chatList[row]
+ defer pages.RemovePage(historyPage)
+ switch tc.Text {
+ case "load":
+ history, err := loadHistoryChat(selectedChat)
+ if err != nil {
+ logger.Error("failed to read history file", "chat", selectedChat)
+ pages.RemovePage(historyPage)
+ return
+ }
+ chatBody.Messages = history
+ textView.SetText(chatToText(cfg.ShowSys))
+ activeChatName = selectedChat
+ pages.RemovePage(historyPage)
+ return
+ case "rename":
+ pages.RemovePage(historyPage)
+ pages.AddPage(renamePage, renameWindow, true, true)
+ return
+ case "delete":
+ sc, ok := chatMap[selectedChat]
+ if !ok {
+ // no chat found
+ pages.RemovePage(historyPage)
+ return
+ }
+ if err := store.RemoveChat(sc.ID); err != nil {
+ logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name)
+ }
+ if err := notifyUser("chat deleted", selectedChat+" was deleted"); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ // load last chat
+ chatBody.Messages = loadOldChatOrGetNew()
+ 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
+ if err := notifyUser("error", "no such card: "+agentName); err != nil {
+ logger.Warn("failed ot notify", "error", err)
+ }
+ return
+ }
+ // if chatBody.Messages[0].Role != "system" || chatBody.Messages[1].Role != agentName {
+ // if err := notifyUser("error", "unexpected chat structure; card: "+agentName); err != nil {
+ // logger.Warn("failed ot notify", "error", err)
+ // }
+ // return
+ // }
+ // change sys_prompt + first msg
+ cc.SysPrompt = chatBody.Messages[0].Content
+ cc.FirstMsg = chatBody.Messages[1].Content
+ if err := pngmeta.WriteToPng(cc.ToSpec(cfg.UserRole), cc.FilePath, cc.FilePath); err != nil {
+ logger.Error("failed to write charcard",
+ "error", err)
+ }
+ return
+ case "move sysprompt onto 1st msg":
+ chatBody.Messages[1].Content = chatBody.Messages[0].Content + chatBody.Messages[1].Content
+ chatBody.Messages[0].Content = rpDefenitionSysMsg
+ textView.SetText(chatToText(cfg.ShowSys))
+ activeChatName = selectedChat
+ pages.RemovePage(historyPage)
+ return
+ case "new_chat_from_card":
+ // Reread card from file and start fresh chat
+ fi := strings.Index(selectedChat, "_")
+ agentName := selectedChat[fi+1:]
+ cc, ok := sysMap[agentName]
+ if !ok {
+ logger.Warn("no such card", "agent", agentName)
+ if err := notifyUser("error", "no such card: "+agentName); err != nil {
+ logger.Warn("failed to notify", "error", err)
+ }
+ return
+ }
+ // Reload card from disk
+ newCard, err := pngmeta.ReadCard(cc.FilePath, cfg.UserRole)
+ if err != nil {
+ logger.Error("failed to reload charcard", "path", cc.FilePath, "error", err)
+ if err := notifyUser("error", "failed to reload card: "+cc.FilePath); err != nil {
+ logger.Warn("failed to notify", "error", err)
+ }
+ return
+ }
+ // Update sysMap with fresh card data
+ sysMap[agentName] = newCard
+ applyCharCard(newCard)
+ startNewChat()
+ pages.RemovePage(historyPage)
+ return
+ default:
+ return
+ }
+ })
+ return chatActTable
+}
+
+// nolint:unused
+func makeRAGTable(fileList []string) *tview.Flex {
+ actions := []string{"load", "delete"}
+ rows, cols := len(fileList), len(actions)+1
+ fileTable := tview.NewTable().
+ SetBorders(true)
+ longStatusView := tview.NewTextView()
+ longStatusView.SetText("status text")
+ longStatusView.SetBorder(true).SetTitle("status")
+ longStatusView.SetChangedFunc(func() {
+ app.Draw()
+ })
+ ragflex := tview.NewFlex().SetDirection(tview.FlexRow).
+ AddItem(longStatusView, 0, 10, false).
+ AddItem(fileTable, 0, 60, true)
+ for r := 0; r < rows; r++ {
+ for c := 0; c < cols; c++ {
+ color := tcell.ColorWhite
+ if c < 1 {
+ fileTable.SetCell(r, c,
+ tview.NewTableCell(fileList[r]).
+ SetTextColor(color).
+ SetAlign(tview.AlignCenter))
+ } else {
+ fileTable.SetCell(r, c,
+ tview.NewTableCell(actions[c-1]).
+ SetTextColor(color).
+ SetAlign(tview.AlignCenter))
+ }
+ }
+ }
+ errCh := make(chan error, 1)
+ go func() {
+ defer pages.RemovePage(RAGPage)
+ for {
+ select {
+ case err := <-errCh:
+ if err == nil {
+ logger.Error("somehow got a nil err", "error", err)
+ continue
+ }
+ logger.Error("got an err in rag status", "error", err, "textview", longStatusView)
+ longStatusView.SetText(fmt.Sprintf("%v", err))
+ close(errCh)
+ return
+ case status := <-rag.LongJobStatusCh:
+ longStatusView.SetText(status)
+ // fmt.Fprintln(longStatusView, status)
+ // app.Sync()
+ if status == rag.FinishedRAGStatus {
+ close(errCh)
+ time.Sleep(2 * time.Second)
+ return
+ }
+ }
+ }
+ }()
+ fileTable.Select(0, 0).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) {
+ if key == tcell.KeyEsc || key == tcell.KeyF1 {
+ pages.RemovePage(RAGPage)
+ return
+ }
+ if key == tcell.KeyEnter {
+ fileTable.SetSelectable(true, true)
+ }
+ }).SetSelectedFunc(func(row int, column int) {
+ // defer pages.RemovePage(RAGPage)
+ tc := fileTable.GetCell(row, column)
+ tc.SetTextColor(tcell.ColorRed)
+ fileTable.SetSelectable(false, false)
+ fpath := fileList[row]
+ // notification := fmt.Sprintf("chat: %s; action: %s", fpath, tc.Text)
+ switch tc.Text {
+ case "load":
+ fpath = path.Join(cfg.RAGDir, fpath)
+ longStatusView.SetText("clicked load")
+ go func() {
+ if err := ragger.LoadRAG(fpath); err != nil {
+ logger.Error("failed to embed file", "chat", fpath, "error", err)
+ errCh <- err
+ // pages.RemovePage(RAGPage)
+ return
+ }
+ }()
+ return
+ case "delete":
+ fpath = path.Join(cfg.RAGDir, fpath)
+ if err := os.Remove(fpath); err != nil {
+ logger.Error("failed to delete file", "filename", fpath, "error", err)
+ return
+ }
+ if err := notifyUser("chat deleted", fpath+" was deleted"); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ return
+ default:
+ return
+ }
+ })
+ return ragflex
+}
+
+// func makeLoadedRAGTable(fileList []string) *tview.Table {
+// actions := []string{"delete"}
+// rows, cols := len(fileList), len(actions)+1
+// fileTable := tview.NewTable().
+// SetBorders(true)
+// for r := 0; r < rows; r++ {
+// for c := 0; c < cols; c++ {
+// color := tcell.ColorWhite
+// if c < 1 {
+// fileTable.SetCell(r, c,
+// tview.NewTableCell(fileList[r]).
+// SetTextColor(color).
+// SetAlign(tview.AlignCenter))
+// } else {
+// fileTable.SetCell(r, c,
+// tview.NewTableCell(actions[c-1]).
+// SetTextColor(color).
+// SetAlign(tview.AlignCenter))
+// }
+// }
+// }
+// fileTable.Select(0, 0).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) {
+// if key == tcell.KeyEsc || key == tcell.KeyF1 {
+// pages.RemovePage(RAGPage)
+// return
+// }
+// if key == tcell.KeyEnter {
+// fileTable.SetSelectable(true, true)
+// }
+// }).SetSelectedFunc(func(row int, column int) {
+// defer pages.RemovePage(RAGPage)
+// tc := fileTable.GetCell(row, column)
+// tc.SetTextColor(tcell.ColorRed)
+// fileTable.SetSelectable(false, false)
+// fpath := fileList[row]
+// // notification := fmt.Sprintf("chat: %s; action: %s", fpath, tc.Text)
+// switch tc.Text {
+// case "delete":
+// if err := ragger.RemoveFile(fpath); err != nil {
+// logger.Error("failed to delete file", "filename", fpath, "error", err)
+// return
+// }
+// if err := notifyUser("chat deleted", fpath+" was deleted"); err != nil {
+// logger.Error("failed to send notification", "error", err)
+// }
+// return
+// default:
+// // pages.RemovePage(RAGPage)
+// return
+// }
+// })
+// return fileTable
+// }
+
+func makeAgentTable(agentList []string) *tview.Table {
+ actions := []string{"load"}
+ rows, cols := len(agentList), len(actions)+1
+ chatActTable := tview.NewTable().
+ SetBorders(true)
+ for r := 0; r < rows; r++ {
+ for c := 0; c < cols; c++ {
+ color := tcell.ColorWhite
+ if c < 1 {
+ chatActTable.SetCell(r, c,
+ tview.NewTableCell(agentList[r]).
+ SetTextColor(color).
+ SetAlign(tview.AlignCenter))
+ } else {
+ chatActTable.SetCell(r, c,
+ tview.NewTableCell(actions[c-1]).
+ SetTextColor(color).
+ SetAlign(tview.AlignCenter))
+ }
+ }
+ }
+ chatActTable.Select(0, 0).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) {
+ if key == tcell.KeyEsc || key == tcell.KeyF1 {
+ pages.RemovePage(agentPage)
+ return
+ }
+ if key == tcell.KeyEnter {
+ chatActTable.SetSelectable(true, true)
+ }
+ }).SetSelectedFunc(func(row int, column int) {
+ tc := chatActTable.GetCell(row, column)
+ tc.SetTextColor(tcell.ColorRed)
+ chatActTable.SetSelectable(false, false)
+ selected := agentList[row]
+ // notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text)
+ switch tc.Text {
+ case "load":
+ if ok := charToStart(selected); !ok {
+ logger.Warn("no such sys msg", "name", selected)
+ pages.RemovePage(agentPage)
+ return
+ }
+ // replace textview
+ textView.SetText(chatToText(cfg.ShowSys))
+ colorText()
+ updateStatusLine()
+ // sysModal.ClearButtons()
+ pages.RemovePage(agentPage)
+ app.SetFocus(textArea)
+ return
+ case "rename":
+ pages.RemovePage(agentPage)
+ pages.AddPage(renamePage, renameWindow, true, true)
+ return
+ case "delete":
+ sc, ok := chatMap[selected]
+ if !ok {
+ // no chat found
+ pages.RemovePage(agentPage)
+ return
+ }
+ if err := store.RemoveChat(sc.ID); err != nil {
+ logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name)
+ }
+ if err := notifyUser("chat deleted", selected+" was deleted"); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ pages.RemovePage(agentPage)
+ return
+ default:
+ pages.RemovePage(agentPage)
+ return
+ }
+ })
+ return chatActTable
+}
+
+func makeCodeBlockTable(codeBlocks []string) *tview.Table {
+ actions := []string{"copy"}
+ rows, cols := len(codeBlocks), len(actions)+1
+ table := tview.NewTable().
+ SetBorders(true)
+ for r := 0; r < rows; r++ {
+ for c := 0; c < cols; c++ {
+ color := tcell.ColorWhite
+ previewLen := 30
+ if len(codeBlocks[r]) < 30 {
+ previewLen = len(codeBlocks[r])
+ }
+ if c < 1 {
+ table.SetCell(r, c,
+ tview.NewTableCell(codeBlocks[r][:previewLen]).
+ SetTextColor(color).
+ SetAlign(tview.AlignCenter))
+ } else {
+ table.SetCell(r, c,
+ tview.NewTableCell(actions[c-1]).
+ SetTextColor(color).
+ SetAlign(tview.AlignCenter))
+ }
+ }
+ }
+ table.Select(0, 0).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) {
+ if key == tcell.KeyEsc || key == tcell.KeyF1 {
+ pages.RemovePage(agentPage)
+ return
+ }
+ if key == tcell.KeyEnter {
+ table.SetSelectable(true, true)
+ }
+ }).SetSelectedFunc(func(row int, column int) {
+ tc := table.GetCell(row, column)
+ tc.SetTextColor(tcell.ColorRed)
+ table.SetSelectable(false, false)
+ selected := codeBlocks[row]
+ // notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text)
+ switch tc.Text {
+ case "copy":
+ if err := copyToClipboard(selected); err != nil {
+ if err := notifyUser("error", err.Error()); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ }
+ if err := notifyUser("copied", selected); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ pages.RemovePage(codeBlockPage)
+ app.SetFocus(textArea)
+ return
+ default:
+ pages.RemovePage(codeBlockPage)
+ return
+ }
+ })
+ return table
+}
+
+func makeImportChatTable(filenames []string) *tview.Table {
+ actions := []string{"load"}
+ rows, cols := len(filenames), len(actions)+1
+ chatActTable := tview.NewTable().
+ SetBorders(true)
+ for r := 0; r < rows; r++ {
+ for c := 0; c < cols; c++ {
+ color := tcell.ColorWhite
+ if c < 1 {
+ chatActTable.SetCell(r, c,
+ tview.NewTableCell(filenames[r]).
+ SetTextColor(color).
+ SetAlign(tview.AlignCenter))
+ } else {
+ chatActTable.SetCell(r, c,
+ tview.NewTableCell(actions[c-1]).
+ SetTextColor(color).
+ SetAlign(tview.AlignCenter))
+ }
+ }
+ }
+ chatActTable.Select(0, 0).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) {
+ if key == tcell.KeyEsc || key == tcell.KeyF1 {
+ pages.RemovePage(historyPage)
+ return
+ }
+ if key == tcell.KeyEnter {
+ chatActTable.SetSelectable(true, true)
+ }
+ }).SetSelectedFunc(func(row int, column int) {
+ tc := chatActTable.GetCell(row, column)
+ tc.SetTextColor(tcell.ColorRed)
+ chatActTable.SetSelectable(false, false)
+ selected := filenames[row]
+ // notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text)
+ switch tc.Text {
+ case "load":
+ if err := importChat(selected); err != nil {
+ logger.Warn("failed to import chat", "filename", selected)
+ pages.RemovePage(historyPage)
+ return
+ }
+ colorText()
+ updateStatusLine()
+ // redraw the text in text area
+ textView.SetText(chatToText(cfg.ShowSys))
+ pages.RemovePage(historyPage)
+ app.SetFocus(textArea)
+ return
+ case "rename":
+ pages.RemovePage(historyPage)
+ pages.AddPage(renamePage, renameWindow, true, true)
+ return
+ case "delete":
+ sc, ok := chatMap[selected]
+ if !ok {
+ // no chat found
+ pages.RemovePage(historyPage)
+ return
+ }
+ if err := store.RemoveChat(sc.ID); err != nil {
+ logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name)
+ }
+ if err := notifyUser("chat deleted", selected+" was deleted"); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ pages.RemovePage(historyPage)
+ return
+ default:
+ pages.RemovePage(historyPage)
+ return
+ }
+ })
+ return chatActTable
+}
diff --git a/tools.go b/tools.go
index 82f07b3..fe95ce5 100644
--- a/tools.go
+++ b/tools.go
@@ -1,59 +1,143 @@
package main
+import (
+ "gf-lt/models"
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+)
+
var (
- // TODO: form that message based on existing funcs
- systemMsg = `You're a helpful assistant.
-# Tools
-You can do functions call if needed.
+ toolCallRE = regexp.MustCompile(`__tool_call__\s*([\s\S]*?)__tool_call__`)
+ quotesRE = regexp.MustCompile(`(".*?")`)
+ starRE = regexp.MustCompile(`(\*.*?\*)`)
+ thinkRE = regexp.MustCompile(`(<think>\s*([\s\S]*?)</think>)`)
+ codeBlockRE = regexp.MustCompile(`(?s)\x60{3}(?:.*?)\n(.*?)\n\s*\x60{3}\s*`)
+ rpDefenitionSysMsg = `
+For this roleplay immersion is at most importance.
+Every character thinks and acts based on their personality and setting of the roleplay.
+Meta discussions outside of roleplay is allowed if clearly labeled as out of character, for example: (ooc: {msg}) or <ooc>{msg}</ooc>.
+`
+ basicSysMsg = `Large Language Model that helps user with any of his requests.`
+ toolSysMsg = `You can do functions call if needed.
Your current tools:
<tools>
+[
+{
+"name":"recall",
+"args": ["topic"],
+"when_to_use": "when asked about topic that user previously asked to memorise"
+},
{
-"name":"get_id",
-"args": "username"
+"name":"memorise",
+"args": ["topic", "info"],
+"when_to_use": "when asked to memorise something"
+},
+{
+"name":"recall_topics",
+"args": [],
+"when_to_use": "to see what topics are saved in memory"
}
+]
</tools>
To make a function call return a json object within __tool_call__ tags;
-Example:
+<example_request>
__tool_call__
{
-"name":"get_id",
-"args": "Adam"
+"name":"recall",
+"args": ["Adam's number"]
}
__tool_call__
-When making function call avoid typing anything else. 'tool' user will respond with the results of the call.
+</example_request>
+Tool call is addressed to the tool agent, avoid sending more info than tool call itself, while making a call.
+When done right, tool call will be delivered to the tool agent. tool agent will respond with the results of the call.
+<example_response>
+tool:
+under the topic: Adam's number is stored:
+559-996
+</example_response>
After that you are free to respond to the user.
`
+ 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{"basic_sys", "tool_sys"}
)
-func memorize(topic, info string) {
- //
-}
-
-func recall(topic string) string {
- //
- return ""
+/*
+consider cases:
+- append mode (treat it like a journal appendix)
+- replace mode (new info/mind invalidates old ones)
+also:
+- some writing can be done without consideration of previous data;
+- others do;
+*/
+func memorise(args ...string) []byte {
+ agent := cfg.AssistantRole
+ if len(args) < 2 {
+ msg := "not enough args to call memorise tool; need topic and data to remember"
+ logger.Error(msg)
+ return []byte(msg)
+ }
+ memory := &models.Memory{
+ Agent: agent,
+ Topic: args[0],
+ Mind: args[1],
+ UpdatedAt: time.Now(),
+ }
+ if _, err := store.Memorise(memory); err != nil {
+ logger.Error("failed to save memory", "err", err, "memoory", memory)
+ return []byte("failed to save info")
+ }
+ msg := "info saved under the topic:" + args[0]
+ return []byte(msg)
}
-func recallTopics() []string {
- return []string{}
+func recall(args ...string) []byte {
+ agent := cfg.AssistantRole
+ if len(args) < 1 {
+ logger.Warn("not enough args to call recall tool")
+ return nil
+ }
+ mind, err := store.Recall(agent, args[0])
+ if err != nil {
+ msg := fmt.Sprintf("failed to recall; error: %v; args: %v", err, args)
+ logger.Error(msg)
+ return []byte(msg)
+ }
+ answer := fmt.Sprintf("under the topic: %s is stored:\n%s", args[0], mind)
+ return []byte(answer)
}
-func fullMemoryLoad() {}
-
-// predifine funcs
-func getUserDetails(id ...string) map[string]any {
- // db query
- // return DB[id[0]]
- return map[string]any{
- "username": "fm11",
- "id": 24983,
- "reputation": 911,
- "balance": 214.73,
+func recallTopics(args ...string) []byte {
+ agent := cfg.AssistantRole
+ topics, err := store.RecallTopics(agent)
+ if err != nil {
+ logger.Error("failed to use tool", "error", err, "args", args)
+ return nil
}
+ joinedS := strings.Join(topics, ";")
+ return []byte(joinedS)
}
-type fnSig func(...string) map[string]any
+// func fullMemoryLoad() {}
+
+type fnSig func(...string) []byte
var fnMap = map[string]fnSig{
- "get_id": getUserDetails,
+ "recall": recall,
+ "recall_topics": recallTopics,
+ "memorise": memorise,
}
diff --git a/tui.go b/tui.go
new file mode 100644
index 0000000..2b5c599
--- /dev/null
+++ b/tui.go
@@ -0,0 +1,800 @@
+package main
+
+import (
+ "fmt"
+ "gf-lt/extra"
+ "gf-lt/models"
+ "gf-lt/pngmeta"
+ "image"
+ _ "image/jpeg"
+ _ "image/png"
+ "os"
+ "path"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+var (
+ app *tview.Application
+ pages *tview.Pages
+ textArea *tview.TextArea
+ editArea *tview.TextArea
+ textView *tview.TextView
+ position *tview.TextView
+ helpView *tview.TextView
+ flex *tview.Flex
+ imgView *tview.Image
+ defaultImage = "sysprompts/llama.png"
+ indexPickWindow *tview.InputField
+ renameWindow *tview.InputField
+ // pages
+ historyPage = "historyPage"
+ agentPage = "agentPage"
+ editMsgPage = "editMsgPage"
+ indexPage = "indexPage"
+ helpPage = "helpPage"
+ renamePage = "renamePage"
+ RAGPage = "RAGPage"
+ propsPage = "propsPage"
+ codeBlockPage = "codeBlockPage"
+ imgPage = "imgPage"
+ // help text
+ // [yellow]F10[white]: manage loaded rag files (that already in vector db)
+ helpText = `
+[yellow]Esc[white]: send msg
+[yellow]PgUp/Down[white]: switch focus between input and chat widgets
+[yellow]F1[white]: manage chats
+[yellow]F2[white]: regen last
+[yellow]F3[white]: delete last msg
+[yellow]F4[white]: edit msg
+[yellow]F5[white]: toggle system
+[yellow]F6[white]: interrupt bot resp
+[yellow]F7[white]: copy last msg to clipboard (linux xclip)
+[yellow]F8[white]: copy n msg to clipboard (linux xclip)
+[yellow]F9[white]: table to copy from; with all code blocks
+[yellow]F11[white]: import chat file
+[yellow]F12[white]: show this help page
+[yellow]Ctrl+w[white]: resume generation on the last msg
+[yellow]Ctrl+s[white]: load new char/agent
+[yellow]Ctrl+e[white]: export chat to json file
+[yellow]Ctrl+n[white]: start a new chat
+[yellow]Ctrl+c[white]: close programm
+[yellow]Ctrl+p[white]: props edit form (min-p, dry, etc.)
+[yellow]Ctrl+v[white]: switch between /completion and /chat api (if provided in config)
+[yellow]Ctrl+r[white]: start/stop recording from your microphone (needs stt server)
+[yellow]Ctrl+t[white]: remove thinking (<think>) and tool messages from context (delete from chat)
+[yellow]Ctrl+l[white]: update connected model name (llamacpp)
+[yellow]Ctrl+k[white]: switch tool use (recommend tool use to llm after user msg)
+[yellow]Ctrl+j[white]: if chat agent is char.png will show the image; then any key to return
+[yellow]Ctrl+a[white]: interrupt tts (needs tts server)
+
+Press Enter to go back
+`
+)
+
+func loadImage() {
+ filepath := defaultImage
+ cc, ok := sysMap[cfg.AssistantRole]
+ if ok {
+ if strings.HasSuffix(cc.FilePath, ".png") {
+ filepath = cc.FilePath
+ }
+ }
+ file, err := os.Open(filepath)
+ if err != nil {
+ panic(err)
+ }
+ defer file.Close()
+ img, _, err := image.Decode(file)
+ if err != nil {
+ panic(err)
+ }
+ imgView.SetImage(img)
+}
+
+func colorText() {
+ text := textView.GetText(false)
+ // Step 1: Extract code blocks and replace them with unique placeholders
+ var codeBlocks []string
+ placeholder := "__CODE_BLOCK_%d__"
+ counter := 0
+ // thinking
+ var thinkBlocks []string
+ placeholderThink := "__THINK_BLOCK_%d__"
+ counterThink := 0
+ // Replace code blocks with placeholders and store their styled versions
+ text = codeBlockRE.ReplaceAllStringFunc(text, func(match string) string {
+ // Style the code block and store it
+ styled := fmt.Sprintf("[red::i]%s[-:-:-]", match)
+ codeBlocks = append(codeBlocks, styled)
+ // Generate a unique placeholder (e.g., "__CODE_BLOCK_0__")
+ id := fmt.Sprintf(placeholder, counter)
+ counter++
+ return id
+ })
+ text = thinkRE.ReplaceAllStringFunc(text, func(match string) string {
+ // Style the code block and store it
+ styled := fmt.Sprintf("[red::i]%s[-:-:-]", match)
+ thinkBlocks = append(thinkBlocks, styled)
+ // Generate a unique placeholder (e.g., "__CODE_BLOCK_0__")
+ id := fmt.Sprintf(placeholderThink, counterThink)
+ counterThink++
+ return id
+ })
+ // Step 2: Apply other regex styles to the non-code parts
+ text = quotesRE.ReplaceAllString(text, `[orange::-]$1[-:-:-]`)
+ text = starRE.ReplaceAllString(text, `[turquoise::i]$1[-:-:-]`)
+ // text = thinkRE.ReplaceAllString(text, `[yellow::i]$1[-:-:-]`)
+ // Step 3: Restore the styled code blocks from placeholders
+ for i, cb := range codeBlocks {
+ text = strings.Replace(text, fmt.Sprintf(placeholder, i), cb, 1)
+ }
+ logger.Debug("thinking debug", "blocks", thinkBlocks)
+ for i, tb := range thinkBlocks {
+ text = strings.Replace(text, fmt.Sprintf(placeholderThink, i), tb, 1)
+ }
+ textView.SetText(text)
+}
+
+func updateStatusLine() {
+ isRecording := false
+ if asr != nil {
+ isRecording = asr.IsRecording()
+ }
+ position.SetText(fmt.Sprintf(indexLine, botRespMode, cfg.AssistantRole, activeChatName, cfg.ToolUse, chatBody.Model, cfg.CurrentAPI, cfg.ThinkUse, logLevel.Level(), isRecording))
+}
+
+func initSysCards() ([]string, error) {
+ labels := []string{}
+ labels = append(labels, sysLabels...)
+ cards, err := pngmeta.ReadDirCards(cfg.SysDir, cfg.UserRole, logger)
+ if err != nil {
+ logger.Error("failed to read sys dir", "error", err)
+ return nil, err
+ }
+ for _, cc := range cards {
+ if cc.Role == "" {
+ logger.Warn("empty role", "file", cc.FilePath)
+ continue
+ }
+ sysMap[cc.Role] = cc
+ labels = append(labels, cc.Role)
+ }
+ return labels, nil
+}
+
+func renameUser(oldname, newname string) {
+ if oldname == "" {
+ // not provided; deduce who user is
+ // INFO: if user not yet spoke, it is hard to replace mentions in sysprompt and first message about thme
+ roles := chatBody.ListRoles()
+ for _, role := range roles {
+ if role == cfg.AssistantRole {
+ continue
+ }
+ if role == cfg.ToolRole {
+ continue
+ }
+ if role == "system" {
+ continue
+ }
+ oldname = role
+ break
+ }
+ if oldname == "" {
+ // still
+ logger.Warn("fn: renameUser; failed to find old name", "newname", newname)
+ return
+ }
+ }
+ viewText := textView.GetText(false)
+ viewText = strings.ReplaceAll(viewText, oldname, newname)
+ chatBody.Rename(oldname, newname)
+ textView.SetText(viewText)
+}
+
+func startNewChat() {
+ id, err := store.ChatGetMaxID()
+ if err != nil {
+ logger.Error("failed to get chat id", "error", err)
+ }
+ if ok := charToStart(cfg.AssistantRole); !ok {
+ logger.Warn("no such sys msg", "name", cfg.AssistantRole)
+ }
+ // set chat body
+ chatBody.Messages = chatBody.Messages[:2]
+ textView.SetText(chatToText(cfg.ShowSys))
+ newChat := &models.Chat{
+ ID: id + 1,
+ Name: fmt.Sprintf("%d_%s", id+1, cfg.AssistantRole),
+ Msgs: string(defaultStarterBytes),
+ Agent: cfg.AssistantRole,
+ }
+ activeChatName = newChat.Name
+ chatMap[newChat.Name] = newChat
+ updateStatusLine()
+ colorText()
+}
+
+func setLogLevel(sl string) {
+ switch sl {
+ case "Debug":
+ logLevel.Set(-4)
+ case "Info":
+ logLevel.Set(0)
+ case "Warn":
+ logLevel.Set(4)
+ }
+}
+
+func makePropsForm(props map[string]float32) *tview.Form {
+ // https://github.com/rivo/tview/commit/0a18dea458148770d212d348f656988df75ff341
+ // no way to close a form by a key press; a shame.
+ form := tview.NewForm().
+ AddTextView("Notes", "Props for llamacpp completion call", 40, 2, true, false).
+ AddCheckbox("Insert <think> (/completion only)", cfg.ThinkUse, func(checked bool) {
+ cfg.ThinkUse = checked
+ }).AddCheckbox("RAG use", cfg.RAGEnabled, func(checked bool) {
+ cfg.RAGEnabled = checked
+ }).AddDropDown("Set log level (Enter): ", []string{"Debug", "Info", "Warn"}, 1,
+ func(option string, optionIndex int) {
+ setLogLevel(option)
+ }).AddDropDown("Select an api: ", slices.Insert(cfg.ApiLinks, 0, cfg.CurrentAPI), 0,
+ func(option string, optionIndex int) {
+ cfg.CurrentAPI = option
+ }).AddDropDown("Select a model: ", []string{chatBody.Model, "deepseek-chat", "deepseek-reasoner"}, 0,
+ func(option string, optionIndex int) {
+ chatBody.Model = option
+ }).AddInputField("username: ", cfg.UserRole, 32, tview.InputFieldMaxLength(32), func(text string) {
+ if text != "" {
+ renameUser(cfg.UserRole, text)
+ cfg.UserRole = text
+ }
+ }).
+ AddButton("Quit", func() {
+ pages.RemovePage(propsPage)
+ })
+ form.AddButton("Save", func() {
+ defer updateStatusLine()
+ defer pages.RemovePage(propsPage)
+ for pn := range props {
+ propField, ok := form.GetFormItemByLabel(pn).(*tview.InputField)
+ if !ok {
+ logger.Warn("failed to convert to inputfield", "prop_name", pn)
+ continue
+ }
+ val, err := strconv.ParseFloat(propField.GetText(), 32)
+ if err != nil {
+ logger.Warn("failed parse to float", "value", propField.GetText())
+ continue
+ }
+ props[pn] = float32(val)
+ }
+ })
+ for propName, value := range props {
+ form.AddInputField(propName, fmt.Sprintf("%v", value), 20, tview.InputFieldFloat, nil)
+ }
+ form.SetBorder(true).SetTitle("Enter some data").SetTitleAlign(tview.AlignLeft)
+ return form
+}
+
+func init() {
+ theme := tview.Theme{
+ PrimitiveBackgroundColor: tcell.ColorDefault,
+ ContrastBackgroundColor: tcell.ColorGray,
+ MoreContrastBackgroundColor: tcell.ColorNavy,
+ BorderColor: tcell.ColorGray,
+ TitleColor: tcell.ColorRed,
+ GraphicsColor: tcell.ColorBlue,
+ PrimaryTextColor: tcell.ColorLightGray,
+ SecondaryTextColor: tcell.ColorYellow,
+ TertiaryTextColor: tcell.ColorOrange,
+ InverseTextColor: tcell.ColorPurple,
+ ContrastSecondaryTextColor: tcell.ColorLime,
+ }
+ tview.Styles = theme
+ app = tview.NewApplication()
+ pages = tview.NewPages()
+ textArea = tview.NewTextArea().
+ SetPlaceholder("Type your prompt...")
+ textArea.SetBorder(true).SetTitle("input")
+ textView = tview.NewTextView().
+ SetDynamicColors(true).
+ SetRegions(true).
+ SetChangedFunc(func() {
+ app.Draw()
+ })
+ // 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().
+ SetDynamicColors(true).
+ SetTextAlign(tview.AlignCenter)
+ position.SetChangedFunc(func() {
+ app.Draw()
+ })
+ flex = tview.NewFlex().SetDirection(tview.FlexRow).
+ AddItem(textView, 0, 40, false).
+ AddItem(textArea, 0, 10, true).
+ AddItem(position, 0, 2, false)
+ editArea = tview.NewTextArea().
+ SetPlaceholder("Replace msg...")
+ editArea.SetBorder(true).SetTitle("input")
+ editArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ // if event.Key() == tcell.KeyEscape && editMode {
+ if event.Key() == tcell.KeyEscape {
+ logger.Warn("edit debug; esc is pressed")
+ defer colorText()
+ editedMsg := editArea.GetText()
+ if editedMsg == "" {
+ if err := notifyUser("edit", "no edit provided"); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ pages.RemovePage(editMsgPage)
+ return nil
+ }
+ chatBody.Messages[selectedIndex].Content = editedMsg
+ // change textarea
+ textView.SetText(chatToText(cfg.ShowSys))
+ pages.RemovePage(editMsgPage)
+ editMode = false
+ return nil
+ }
+ return event
+ })
+ indexPickWindow = tview.NewInputField().
+ SetLabel("Enter a msg index: ").
+ SetFieldWidth(4).
+ SetAcceptanceFunc(tview.InputFieldInteger).
+ SetDoneFunc(func(key tcell.Key) {
+ defer indexPickWindow.SetText("")
+ pages.RemovePage(indexPage)
+ // colorText()
+ // updateStatusLine()
+ })
+ indexPickWindow.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() {
+ case tcell.KeyBackspace:
+ return event
+ case tcell.KeyEnter:
+ si := indexPickWindow.GetText()
+ siInt, err := strconv.Atoi(si)
+ if err != nil {
+ logger.Error("failed to convert provided index", "error", err, "si", si)
+ if err := notifyUser("cancel", "no index provided"); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ pages.RemovePage(indexPage)
+ return event
+ }
+ selectedIndex = siInt
+ if len(chatBody.Messages)+1 < selectedIndex || selectedIndex < 0 {
+ msg := "chosen index is out of bounds"
+ logger.Warn(msg, "index", selectedIndex)
+ if err := notifyUser("error", msg); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ pages.RemovePage(indexPage)
+ return event
+ }
+ m := chatBody.Messages[selectedIndex]
+ if editMode && event.Key() == tcell.KeyEnter {
+ pages.RemovePage(indexPage)
+ pages.AddPage(editMsgPage, editArea, true, true)
+ editArea.SetText(m.Content, true)
+ }
+ if !editMode && event.Key() == tcell.KeyEnter {
+ if err := copyToClipboard(m.Content); err != nil {
+ logger.Error("failed to copy to clipboard", "error", err)
+ }
+ previewLen := 30
+ if len(m.Content) < 30 {
+ previewLen = len(m.Content)
+ }
+ notification := fmt.Sprintf("msg '%s' was copied to the clipboard", m.Content[:previewLen])
+ if err := notifyUser("copied", notification); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ }
+ return event
+ default:
+ return event
+ }
+ })
+ //
+ renameWindow = tview.NewInputField().
+ SetLabel("Enter a msg index: ").
+ SetFieldWidth(20).
+ SetAcceptanceFunc(tview.InputFieldMaxLength(100)).
+ SetDoneFunc(func(key tcell.Key) {
+ pages.RemovePage(renamePage)
+ })
+ renameWindow.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ if event.Key() == tcell.KeyEnter {
+ nname := renameWindow.GetText()
+ if nname == "" {
+ return event
+ }
+ currentChat := chatMap[activeChatName]
+ delete(chatMap, activeChatName)
+ currentChat.Name = nname
+ activeChatName = nname
+ chatMap[activeChatName] = currentChat
+ _, err := store.UpsertChat(currentChat)
+ if err != nil {
+ logger.Error("failed to upsert chat", "error", err, "chat", currentChat)
+ }
+ notification := fmt.Sprintf("renamed chat to '%s'", activeChatName)
+ if err := notifyUser("renamed", notification); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ }
+ return event
+ })
+ //
+ helpView = tview.NewTextView().SetDynamicColors(true).SetText(helpText).SetDoneFunc(func(key tcell.Key) {
+ pages.RemovePage(helpPage)
+ })
+ helpView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() {
+ case tcell.KeyEsc, tcell.KeyEnter:
+ return event
+ }
+ return nil
+ })
+ //
+ imgView = tview.NewImage()
+ imgView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() {
+ case tcell.KeyEnter:
+ pages.RemovePage(imgPage)
+ return event
+ }
+ if isASCII(string(event.Rune())) {
+ pages.RemovePage(imgPage)
+ return event
+ }
+ return nil
+ })
+ //
+ textArea.SetMovedFunc(updateStatusLine)
+ updateStatusLine()
+ textView.SetText(chatToText(cfg.ShowSys))
+ colorText()
+ textView.ScrollToEnd()
+ // init sysmap
+ _, err := initSysCards()
+ if err != nil {
+ logger.Error("failed to init sys cards", "error", err)
+ }
+ app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ if event.Key() == tcell.KeyF1 {
+ // chatList, err := loadHistoryChats()
+ chatList, err := store.GetChatByChar(cfg.AssistantRole)
+ if err != nil {
+ logger.Error("failed to load chat history", "error", err)
+ return nil
+ }
+ chatMap := make(map[string]models.Chat)
+ // nameList := make([]string, len(chatList))
+ for _, chat := range chatList {
+ // nameList[i] = chat.Name
+ chatMap[chat.Name] = chat
+ }
+ chatActTable := makeChatTable(chatMap)
+ pages.AddPage(historyPage, chatActTable, true, true)
+ colorText()
+ updateStatusLine()
+ return nil
+ }
+ if event.Key() == tcell.KeyF2 {
+ // regen last msg
+ chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
+ // there is no case where user msg is regenerated
+ // lastRole := chatBody.Messages[len(chatBody.Messages)-1].Role
+ textView.SetText(chatToText(cfg.ShowSys))
+ go chatRound("", cfg.UserRole, textView, true, false)
+ return nil
+ }
+ if event.Key() == tcell.KeyF3 && !botRespMode {
+ // delete last msg
+ // check textarea text; if it ends with bot icon delete only icon:
+ text := textView.GetText(true)
+ assistantIcon := roleToIcon(cfg.AssistantRole)
+ if strings.HasSuffix(text, assistantIcon) {
+ logger.Debug("deleting assistant icon", "icon", assistantIcon)
+ textView.SetText(strings.TrimSuffix(text, assistantIcon))
+ colorText()
+ return nil
+ }
+ chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
+ textView.SetText(chatToText(cfg.ShowSys))
+ colorText()
+ return nil
+ }
+ if event.Key() == tcell.KeyF4 {
+ // edit msg
+ editMode = true
+ pages.AddPage(indexPage, indexPickWindow, true, true)
+ return nil
+ }
+ if event.Key() == tcell.KeyF5 {
+ // switch cfg.ShowSys
+ cfg.ShowSys = !cfg.ShowSys
+ textView.SetText(chatToText(cfg.ShowSys))
+ colorText()
+ }
+ if event.Key() == tcell.KeyF6 {
+ interruptResp = true
+ botRespMode = false
+ return nil
+ }
+ if event.Key() == tcell.KeyF7 {
+ // copy msg to clipboard
+ editMode = false
+ m := chatBody.Messages[len(chatBody.Messages)-1]
+ if err := copyToClipboard(m.Content); err != nil {
+ logger.Error("failed to copy to clipboard", "error", err)
+ }
+ previewLen := 30
+ if len(m.Content) < 30 {
+ previewLen = len(m.Content)
+ }
+ notification := fmt.Sprintf("msg '%s' was copied to the clipboard", m.Content[:previewLen])
+ if err := notifyUser("copied", notification); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ return nil
+ }
+ if event.Key() == tcell.KeyF8 {
+ // copy msg to clipboard
+ editMode = false
+ pages.AddPage(indexPage, indexPickWindow, true, true)
+ return nil
+ }
+ if event.Key() == tcell.KeyF9 {
+ // table of codeblocks to copy
+ text := textView.GetText(false)
+ cb := codeBlockRE.FindAllString(text, -1)
+ if len(cb) == 0 {
+ if err := notifyUser("notify", "no code blocks in chat"); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ return nil
+ }
+ table := makeCodeBlockTable(cb)
+ pages.AddPage(codeBlockPage, table, true, true)
+ // updateStatusLine()
+ return nil
+ }
+ // if event.Key() == tcell.KeyF10 {
+ // // list rag loaded in db
+ // loadedFiles, err := ragger.ListLoaded()
+ // if err != nil {
+ // logger.Error("failed to list regfiles in db", "error", err)
+ // return nil
+ // }
+ // if len(loadedFiles) == 0 {
+ // if err := notifyUser("loaded RAG", "no files in db"); err != nil {
+ // logger.Error("failed to send notification", "error", err)
+ // }
+ // return nil
+ // }
+ // dbRAGTable := makeLoadedRAGTable(loadedFiles)
+ // pages.AddPage(RAGPage, dbRAGTable, true, true)
+ // return nil
+ // }
+ if event.Key() == tcell.KeyF11 {
+ // read files in chat_exports
+ dirname := "chat_exports"
+ filelist, err := os.ReadDir(dirname)
+ if err != nil {
+ if err := notifyUser("failed to load exports", err.Error()); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ }
+ fli := []string{}
+ for _, f := range filelist {
+ if f.IsDir() || !strings.HasSuffix(f.Name(), ".json") {
+ continue
+ }
+ fpath := path.Join(dirname, f.Name())
+ fli = append(fli, fpath)
+ }
+ // check error
+ exportsTable := makeImportChatTable(fli)
+ pages.AddPage(historyPage, exportsTable, true, true)
+ updateStatusLine()
+ return nil
+ }
+ if event.Key() == tcell.KeyF12 {
+ // help window cheatsheet
+ pages.AddPage(helpPage, helpView, true, true)
+ return nil
+ }
+ if event.Key() == tcell.KeyCtrlE {
+ // export loaded chat into json file
+ if err := exportChat(); err != nil {
+ logger.Error("failed to export chat;", "error", err, "chat_name", activeChatName)
+ return nil
+ }
+ if err := notifyUser("exported chat", "chat: "+activeChatName+" was exported"); err != nil {
+ logger.Error("failed to send notification", "error", err)
+ }
+ return nil
+ }
+ if event.Key() == tcell.KeyCtrlP {
+ propsForm := makePropsForm(defaultLCPProps)
+ pages.AddPage(propsPage, propsForm, true, true)
+ return nil
+ }
+ if event.Key() == tcell.KeyCtrlN {
+ startNewChat()
+ return nil
+ }
+ if event.Key() == tcell.KeyCtrlL {
+ go func() {
+ fetchModelName() // blocks
+ updateStatusLine()
+ }()
+ return nil
+ }
+ if event.Key() == tcell.KeyCtrlT {
+ // clear context
+ // remove tools and thinking
+ removeThinking(chatBody)
+ textView.SetText(chatToText(cfg.ShowSys))
+ colorText()
+ return nil
+ }
+ if event.Key() == tcell.KeyCtrlV {
+ // switch between /chat and /completion api
+ newAPI := cfg.APIMap[cfg.CurrentAPI]
+ if newAPI == "" {
+ // do not switch
+ return nil
+ }
+ cfg.CurrentAPI = newAPI
+ if strings.Contains(cfg.CurrentAPI, "deepseek") {
+ chatBody.Model = "deepseek-chat"
+ } else {
+ chatBody.Model = "local"
+ }
+ choseChunkParser()
+ updateStatusLine()
+ return nil
+ }
+ if event.Key() == tcell.KeyCtrlS {
+ // switch sys prompt
+ labels, err := initSysCards()
+ 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
+ }
+ at := makeAgentTable(labels)
+ // sysModal.AddButtons(labels)
+ // load all chars
+ pages.AddPage(agentPage, at, true, true)
+ updateStatusLine()
+ return nil
+ }
+ if event.Key() == tcell.KeyCtrlK {
+ // add message from tools
+ cfg.ToolUse = !cfg.ToolUse
+ updateStatusLine()
+ return nil
+ }
+ if event.Key() == tcell.KeyCtrlJ {
+ // show image
+ loadImage()
+ pages.AddPage(imgPage, imgView, true, true)
+ return nil
+ }
+ // TODO: move to menu or table
+ // if event.Key() == tcell.KeyCtrlR && cfg.HFToken != "" {
+ // // rag load
+ // // menu of the text files from defined rag directory
+ // files, err := os.ReadDir(cfg.RAGDir)
+ // if err != nil {
+ // logger.Error("failed to read dir", "dir", cfg.RAGDir, "error", err)
+ // return nil
+ // }
+ // fileList := []string{}
+ // for _, f := range files {
+ // if f.IsDir() {
+ // continue
+ // }
+ // fileList = append(fileList, f.Name())
+ // }
+ // chatRAGTable := makeRAGTable(fileList)
+ // pages.AddPage(RAGPage, chatRAGTable, true, true)
+ // return nil
+ // }
+ if event.Key() == tcell.KeyCtrlR && cfg.STT_ENABLED {
+ defer updateStatusLine()
+ if asr.IsRecording() {
+ userSpeech, err := asr.StopRecording()
+ if err != nil {
+ logger.Error("failed to inference user speech", "error", err)
+ return nil
+ }
+ if userSpeech != "" {
+ // append indtead of replacing
+ prevText := textArea.GetText()
+ textArea.SetText(prevText+userSpeech, true)
+ } else {
+ logger.Warn("empty user speech")
+ }
+ return nil
+ }
+ if err := asr.StartRecording(); err != nil {
+ logger.Error("failed to start recording user speech", "error", err)
+ return nil
+ }
+ }
+ // I need keybind for tts to shut up
+ if event.Key() == tcell.KeyCtrlA {
+ textArea.SetText("pressed ctrl+A", true)
+ if cfg.TTS_ENABLED {
+ // audioStream.TextChan <- chunk
+ extra.TTSDoneChan <- true
+ }
+ }
+ if event.Key() == tcell.KeyCtrlW {
+ // INFO: continue bot/text message
+ // without new role
+ lastRole := chatBody.Messages[len(chatBody.Messages)-1].Role
+ go chatRound("", lastRole, textView, false, true)
+ return nil
+ }
+ // cannot send msg in editMode or botRespMode
+ if event.Key() == tcell.KeyEscape && !editMode && !botRespMode {
+ // read all text into buffer
+ msgText := textArea.GetText()
+ nl := "\n"
+ prevText := textView.GetText(true)
+ // strings.LastIndex()
+ // newline is not needed is prev msg ends with one
+ if strings.HasSuffix(prevText, nl) {
+ nl = ""
+ }
+ if msgText != "" {
+ // add user icon before user msg
+ fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
+ nl, len(chatBody.Messages), cfg.UserRole, msgText)
+ textArea.SetText("", true)
+ textView.ScrollToEnd()
+ colorText()
+ }
+ // update statue line
+ go chatRound(msgText, cfg.UserRole, textView, false, false)
+ return nil
+ }
+ if event.Key() == tcell.KeyPgUp || event.Key() == tcell.KeyPgDn {
+ currentF := app.GetFocus()
+ app.SetFocus(focusSwitcher[currentF])
+ return nil
+ }
+ if isASCII(string(event.Rune())) && !botRespMode {
+ return event
+ }
+ return event
+ })
+}
diff --git a/vec0.so b/vec0.so
new file mode 100755
index 0000000..bd4c3ca
--- /dev/null
+++ b/vec0.so
Binary files differ