diff options
| author | Grail Finder <wohilas@gmail.com> | 2024-11-14 20:02:13 +0300 | 
|---|---|---|
| committer | Grail Finder <wohilas@gmail.com> | 2024-11-14 20:02:13 +0300 | 
| commit | 3cbad31a16bc82ff6e29410927578242d158b97a (patch) | |
| tree | 8df9e744641a7c44251f7f0c98b1275502db004e | |
init
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | bot.go | 242 | ||||
| -rw-r--r-- | go.mod | 18 | ||||
| -rw-r--r-- | go.sum | 50 | ||||
| -rw-r--r-- | main.go | 87 | ||||
| -rw-r--r-- | models/models.go | 90 | 
7 files changed, 502 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92828cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.txt +*.json +testlog +elefant diff --git a/README.md b/README.md new file mode 100644 index 0000000..27ccedf --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +### TODO: +- scrolling chat history; (somewhat works out of box); +- log errors to file; +- regen last message; +- delete last message +- edit message? (including from bot); +- use chatml template (but do not show it to the user); +- use mistral template; +- ability to copy message; +- aility to copy selected text; +- menu with old chats (chat files); @@ -0,0 +1,242 @@ +package main + +import ( +	"bufio" +	"bytes" +	"elefant/models" +	"encoding/json" +	"fmt" +	"io" +	"log/slog" +	"net/http" +	"os" +	"strings" +	"time" + +	"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 = "<🤖>: " +	chunkChan     = make(chan string, 10) +	streamDone    = make(chan bool, 1) +	chatBody      *models.ChatBody +	systemMsg     = `You're a helpful assistant. +# Tools +You can do functions call if needed. +Your current tools: +<tools> +{ +"name":"get_id", +"args": "username" +} +</tools> +To make a function call return a json object within __tool_call__ tags; +Example: +__tool_call__ +{ +"name":"get_id", +"args": "Adam" +} +__tool_call___ +When making function call avoid typing anything else. 'tool' user will respond with the results of the call. +After that you are free to respond to the user. +` +) + +// 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, +	} +} + +type fnSig func(...string) map[string]any + +var fnMap = map[string]fnSig{ +	"get_id": getUserDetails, +} + +// ==== + +func getUserInput(userPrompt string) string { +	// fmt.Printf("<🤖>: %s\n<user>:", botMsg) +	fmt.Printf(userPrompt) +	reader := bufio.NewReader(os.Stdin) +	line, err := reader.ReadString('\n') +	if err != nil { +		panic(err) // think about it +	} +	// fmt.Printf("read line: %s-\n", line) +	return line +} + +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) +	} +	data, err := json.Marshal(chatBody) +	if err != nil { +		panic(err) +	} +	return bytes.NewReader(data) +} + +// func sendMsgToLLM(body io.Reader) (*models.LLMRespChunk, error) { +func sendMsgToLLM(body io.Reader) (any, error) { +	resp, err := httpClient.Post(APIURL, "application/json", body) +	if err != nil { +		logger.Error("llamacpp api", "error", err) +		return nil, err +	} +	llmResp := []models.LLMRespChunk{} +	// chunkChan <- assistantIcon +	reader := bufio.NewReader(resp.Body) +	counter := 0 +	for { +		llmchunk := models.LLMRespChunk{} +		if counter > 2000 { +			streamDone <- true +			break +		} +		line, err := reader.ReadBytes('\n') +		if err != nil { +			streamDone <- true +			panic(err) +		} +		// logger.Info("linecheck", "line", string(line), "len", len(line), "counter", counter) +		if len(line) <= 1 { +			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)) +			streamDone <- true +			return nil, err +		} +		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" { +			streamDone <- true +			// last chunk +			break +		} +		counter++ +		// bot sends way too many \n +		answerText := strings.ReplaceAll(llmchunk.Choices[0].Delta.Content, "\n\n", "\n") +		chunkChan <- answerText +	} +	return llmResp, nil +} + +func chatRound(userMsg, role string, tv *tview.TextView) { +	botRespMode = true +	reader := formMsg(chatBody, userMsg, role) +	go sendMsgToLLM(reader) +	fmt.Fprintf(tv, assistantIcon) +	respText := strings.Builder{} +out: +	for { +		select { +		case chunk := <-chunkChan: +			// fmt.Printf(chunk) +			fmt.Fprintf(tv, chunk) +			respText.WriteString(chunk) +		case <-streamDone: +			break out +		} +	} +	botRespMode = false +	chatBody.Messages = append(chatBody.Messages, models.MessagesStory{ +		Role: assistantRole, Content: respText.String(), +	}) +	// TODO: +	// bot msg is done; +	// now check it for func call +	logChat("testlog", chatBody.Messages) +	findCall(respText.String(), tv) +} + +func logChat(fname string, msgs []models.MessagesStory) { +	data, err := json.MarshalIndent(msgs, "", "  ") +	if err != nil { +		logger.Error("failed to marshal", "error", err) +	} +	if err := os.WriteFile(fname, data, 0666); err != nil { +		logger.Error("failed to write log", "error", err) +	} +} + +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) { +		return +	} +	jsStr := strings.TrimSuffix(strings.TrimPrefix(msg, prefix), suffix) +	if err := json.Unmarshal([]byte(jsStr), &fc); err != nil { +		logger.Error("failed to unmarshal tool call", "error", err) +		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) +		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 +} +func init() { +	file, err := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) +	if err != nil { +		panic(err) +	} +	defer file.Close() +	logger = slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{})) +	logger.Info("test msg") +	firstMsg := "Hello! What can I do for you?" +	// fm, err := fillTempl("chatml", chatml) +	// if err != nil { +	// 	panic(err) +	// } +	// https://github.com/coreydaley/ggerganov-llama.cpp/blob/master/examples/server/README.md +	chatBody = &models.ChatBody{ +		Model:  "modl_name", +		Stream: true, +		Messages: []models.MessagesStory{ +			{Role: "system", Content: systemMsg}, +			{Role: assistantRole, Content: firstMsg}, +		}, +	} +	// fmt.Printf("<🤖>: Hello! How can I help?") +	// for { +	// 	chatLoop() +	// } +} @@ -0,0 +1,18 @@ +module elefant + +go 1.23.2 + +require ( +	github.com/gdamore/tcell/v2 v2.7.4 +	github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592 +) + +require ( +	github.com/gdamore/encoding v1.0.0 // indirect +	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect +	github.com/mattn/go-runewidth v0.0.15 // indirect +	github.com/rivo/uniseg v0.4.7 // indirect +	golang.org/x/sys v0.17.0 // indirect +	golang.org/x/term v0.17.0 // indirect +	golang.org/x/text v0.14.0 // indirect +) @@ -0,0 +1,50 @@ +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= +github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592 h1:YIJ+B1hePP6AgynC5TcqpO0H9k3SSoZa2BGyL6vDUzM= +github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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/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= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.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/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= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +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/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= @@ -0,0 +1,87 @@ +package main + +import ( +	"fmt" +	"unicode" + +	"github.com/gdamore/tcell/v2" +	"github.com/rivo/tview" +) + +var ( +	normalMode  = false +	botRespMode = false +	botMsg      = "no" +	indexLine   = "Row: [yellow]%d[white], Column: [yellow]%d; normal mode: %v" +) + +func isASCII(s string) bool { +	for i := 0; i < len(s); i++ { +		if s[i] > unicode.MaxASCII { +			return false +		} +	} +	return true +} + +func main() { +	app := tview.NewApplication() +	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") +	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, normalMode)) +		} else { +			position.SetText(fmt.Sprintf("[red]From[white] Row: [yellow]%d[white], Column: [yellow]%d[white] - [red]To[white] Row: [yellow]%d[white], To Column: [yellow]%d; normal mode: %v", fromRow, fromColumn, toRow, toColumn, normalMode)) +		} +	} +	textArea.SetMovedFunc(updateStatusLine) +	updateStatusLine() +	textView.SetText("<🤖>: Hello! What can I do for you?") +	app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { +		if botRespMode { +			// do nothing while bot typing +			return nil +		} +		if event.Key() == tcell.KeyEscape { +			fromRow, fromColumn, _, _ := textArea.GetCursor() +			position.SetText(fmt.Sprintf(indexLine, fromRow, fromColumn, normalMode)) +			// read all text into buffer +			msgText := textArea.GetText() +			if msgText != "" { +				fmt.Fprintf(textView, "\n<user>: %s\n", msgText) +				textArea.SetText("", true) +			} +			// update statue line +			go chatRound(msgText, userRole, textView) +			return nil +		} +		if isASCII(string(event.Rune())) { +			// normalMode = false +			// fromRow, fromColumn, _, _ := textArea.GetCursor() +			// position.SetText(fmt.Sprintf(indexLine, fromRow, fromColumn, normalMode)) +			return event +		} +		return event +	}) +	if err := app.SetRoot(flex, +		true).EnableMouse(true).Run(); err != nil { +		panic(err) +	} +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..dd1dace --- /dev/null +++ b/models/models.go @@ -0,0 +1,90 @@ +package models + +// 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"` +} + +type LLMResp struct { +	Choices []struct { +		FinishReason string `json:"finish_reason"` +		Index        int    `json:"index"` +		Message      struct { +			Content string `json:"content"` +			Role    string `json:"role"` +		} `json:"message"` +	} `json:"choices"` +	Created int    `json:"created"` +	Model   string `json:"model"` +	Object  string `json:"object"` +	Usage   struct { +		CompletionTokens int `json:"completion_tokens"` +		PromptTokens     int `json:"prompt_tokens"` +		TotalTokens      int `json:"total_tokens"` +	} `json:"usage"` +	ID string `json:"id"` +} + +// for streaming +type LLMRespChunk struct { +	Choices []struct { +		FinishReason string `json:"finish_reason"` +		Index        int    `json:"index"` +		Delta        struct { +			Content string `json:"content"` +		} `json:"delta"` +	} `json:"choices"` +	Created int    `json:"created"` +	ID      string `json:"id"` +	Model   string `json:"model"` +	Object  string `json:"object"` +	Usage   struct { +		CompletionTokens int `json:"completion_tokens"` +		PromptTokens     int `json:"prompt_tokens"` +		TotalTokens      int `json:"total_tokens"` +	} `json:"usage"` +} + +type MessagesStory struct { +	Role    string `json:"role"` +	Content string `json:"content"` +} + +type ChatBody struct { +	Model    string          `json:"model"` +	Stream   bool            `json:"stream"` +	Messages []MessagesStory `json:"messages"` +} + +type ChatToolsBody struct { +	Model    string          `json:"model"` +	Messages []MessagesStory `json:"messages"` +	Tools    []struct { +		Type     string `json:"type"` +		Function struct { +			Name        string `json:"name"` +			Description string `json:"description"` +			Parameters  struct { +				Type       string `json:"type"` +				Properties struct { +					Location struct { +						Type        string `json:"type"` +						Description string `json:"description"` +					} `json:"location"` +					Unit struct { +						Type string   `json:"type"` +						Enum []string `json:"enum"` +					} `json:"unit"` +				} `json:"properties"` +				Required []string `json:"required"` +			} `json:"parameters"` +		} `json:"function"` +	} `json:"tools"` +	ToolChoice string `json:"tool_choice"` +}  | 
