Cover Page
Backend Page
Go with Echo
handlers module
Change to your chatterd directory and edit handlers.go:
server$ cd ~/reactive/chatterd
server$ vi handlers.go
First add the following imports to the import() block at the top of the file:
"bufio"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
Next define these three structs to help llmchat() deserialize
JSON received from clients. Add these lines right below the import
block:
type OllamaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type OllamaRequest struct {
AppID string `json:"appID"`
Model string `json:"model"`
Messages []OllamaMessage `json:"messages"`
Stream bool `json:"stream"`
}
type OllamaResponse struct {
Model string `json:"model"`
CreatedAt string `json:"created_at"`
Message OllamaMessage `json:"message"`
}
To store the client’s conversation context/history with Ollama in the PostgreSQL
database, llmchat() first confirms that the client has sent an appID that can
be used to tag its entries in the database. Here’s the signature of llmchat()
along with its check for client’s appID:
func llmchat(c echo.Context) error {
err := errors.New("")
req := c.Request()
reqCtx := req.Context()
var ollama_request OllamaRequest
if err = c.Bind(&ollama_request); err != nil {
return logClientErr(c, http.StatusUnprocessableEntity, err)
}
if len(ollama_request.AppID) == 0 {
return logClientErr(c, http.StatusUnprocessableEntity, fmt.Errorf("invalid appID: %s", ollama_request.AppID))
}
// insert into DB
logOk(c)
return nil
}
Once we confirm that the client has an appID, we insert its current prompt
into the database, adding to its conversation history with Ollama. Replace the
comment // insert into DB with the following code:
// insert each message into the database
for _, msg := range ollama_request.Messages {
_, err = chatterDB.Exec(background, `INSERT INTO chatts (username, message, id, appid) VALUES ($1, $2, gen_random_uuid(), $3)`,
msg.Role, msg.Content, ollama_request.AppID)
if err != nil {
return logClientErr(c, http.StatusBadRequest, err)
}
}
// retrieve history
Then we retrieve the client’s conversation history, including the recently
inserted, current prompt, as the last entry, and put them in a JSON format
expected by Ollama’s chat API. Replace // retrieve history with:
// load all messages from database as history to be sent to Ollama
rows, err := chatterDB.Query(reqCtx, `SELECT username, message FROM chatts WHERE appid = $1 ORDER BY time ASC`, ollama_request.AppID)
if err != nil {
if rows != nil {
rows.Close()
}
return logServerErr(c, err)
}
var history []OllamaMessage
for rows.Next() {
var msg OllamaMessage
err = rows.Scan(&msg.Role, &msg.Content)
if err != nil {
rows.Close()
return logServerErr(c, err)
}
history = append(history, msg)
}
ollama_request.Messages = history
payload, err := json.Marshal(&ollama_request) // convert the request to JSON
if err != nil {
return logServerErr(c, err)
}
// send request to Ollama
We send the request so constructed to Ollama, declare an accumulator variable,
full_response, to assemble the reply tokens Ollama streams back.
Replace // send request to Ollama with:
// Send request to Ollama
ollama_url := OLLAMA_BASE_URL.String() + "/chat"
client, _ := http.NewRequestWithContext(reqCtx, req.Method, ollama_url, bytes.NewReader(payload))
response, err := http.DefaultClient.Do(client)
if err != nil {
return logServerErr(c, err)
}
defer func() {
_ = response.Body.Close()
}()
var full_response []string
// prepare response header
As we saw in the first tutorial,
llmPrompt, Ollama streams the replies as a NDJSON stream. We will transform
this NDJSON stream into a stream of SSE events (more details later) to be
returned to the client. We next prepare a response header to be used to send
each SSE event to the client. Replace // prepare response header with:
res := c.Response()
res.Header().Set(echo.HeaderContentType, "text/event-stream")
res.Header().Set(echo.HeaderCacheControl, "no-cache")
// SSE conversion and accumulate completion
For each incoming NDJSON element, we convert it into an OllamaResponse type.
If the conversion is unsuccessful and the model property of the type is empty,
we return an SSE error event and move on to the next NDJSON line. Otherwise,
we append the content in the OllamaResponse to the full_response variable.
Then we send the full NDJSON line as an SSE data line, of the default and implicit
Message event. Replace // SSE conversion and accumulate completion with:
scanner := bufio.NewScanner(response.Body)
for scanner.Scan() {
line := scanner.Text()
var ollama_response OllamaResponse
// deserialize each line into OllamaResponse
if err = json.Unmarshal([]byte(line), &ollama_response); err == nil {
if ollama_response.Model == "" {
_, _ = fmt.Fprintf(res, "event: error\ndata: %s\n\n",
strings.ReplaceAll(line, "\\\"", "'"))
} else {
// append response token to full assistant message
full_response = append(full_response, ollama_response.Message.Content)
// send NDJSON line as SSE data line
_, _ = fmt.Fprintf(res, "data: %s\n\n", line)
}
} else {
jsonErrMsg, _ := json.Marshal(fmt.Sprintf("%s", err))
_, _ = fmt.Fprintf(res, "event: error\ndata: { \"error\": %s }\n\n", string(jsonErrMsg))
}
res.Flush()
}
if err = scanner.Err(); err != nil {
jsonErrMsg, _ := json.Marshal(fmt.Sprintf("%s", err))
_, _ = fmt.Fprintf(res, "event: error\ndata: { \"error\": %s }\n\n", string(jsonErrMsg))
res.Flush()
}
// insert full response into database
When we reach the end of the NDJSON stream, we insert the full Ollama response into PostgreSQL
database as the assistant’s reply. Replace // insert full response into database with:
// Save final assistant message
assistant_response := strings.Join(full_response, "")
if assistant_response != "" {
wsRegex := regexp.MustCompile("\\s+") // \s means whitespace
_, err = chatterDB.Exec(background, `INSERT INTO chatts (username, message, id, appid) VALUES (NULL, $1, gen_random_uuid(), $2)`, // replace 'assistant' with NULL to test error event
wsRegex.ReplaceAllString(assistant_response, " "), ollama_request.AppID)
if err != nil {
jsonErrMsg, _ := json.Marshal(fmt.Sprintf("%s", err))
_, _ = fmt.Fprintf(res, "event: error\ndata: { \"error\": %s }\n\n", string(jsonErrMsg))
res.Flush()
}
}
If we encountered any error in the insertion above, we send an SSE error event to the client.
We’re done with handlers.go. Save and exit the file.
main.go
Edit the file main.go:
server$ vi main.go
Find the global variable router and add this route right
after the route for /llmprompt/:
{"POST", "/llmchat/", llmchat},
We’re done with main.go. Save and exit the file.
Build and test run
To build your server:
server$ go get # -u # to upgrade all packages to the latest version
server$ go build
![]()
Go is a compiled language, like C/C++ and unlike Python, which is an interpreted language. This means you must run go build each and every time you made changes to your code, for the changes to show up in your executable.
To run your server:
server$ sudo ./chatterd
# Hit ^C to end the test
The cover backend spec provides instructions on Testing llmChat API and SSE error handling.
References
| Prepared by Xin Jie ‘Joyce’ Liu, Chenglin Li, and Sugih Jamin | Last updated August 10th, 2025 |