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

:point_right: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