feat: implement terminal messenger with conversation management

feat: implement terminal messenger with conversation management

- Add terminal-based messenger client connecting to Verint API endpoint
- Implement colorized output (blue for user input, green for assistant
responses)
- Add readline integration for arrow key navigation through input
history
- Implement conversation persistence with unique history files per
conversation ID
- Add conversation resume feature listing 5 most recent conversations
- Store and auto-load model names per conversation
- Display last 10 message exchanges when resuming conversations
- Log full conversation history (user + assistant messages) with
timestamps
- Simplify codebase by consolidating file operations into reusable
functions
- Fix Sscanf error handling for conversation selection

Features:
- Request/response handling with conversation context via
previous_response_id
- Three file types per conversation: .tmp (readline history), _log.txt
(full conversation), _meta.txt (model name)
- Automatic conversation ID assignment after first message
- Clean exit handling and proper resource cleanup
This commit is contained in:
Peter Morton 2025-11-01 23:33:57 -05:00
parent c198babc75
commit a5c684aa72
2 changed files with 176 additions and 64 deletions

View File

@ -1,14 +1,16 @@
# Terminal Messenger # Terminal Messenger
A simple, colorful terminal-based messenger application written in Go that connects to a remote messaging endpoint. A simple, colorful terminal-based messenger application written in Go that connects to a remote messaging endpoint with conversation persistence and history management.
## Features ## Features
- 🎨 **Colorized output** - Blue for your input, green for assistant responses - 🎨 **Colorized output** - Blue for your input, green for assistant responses
- ⌨️ **Input history** - Navigate previous messages with arrow keys - ⌨️ **Input history** - Navigate previous messages with arrow keys
- 💬 **Conversation context** - Maintains conversation continuity across messages - 💾 **Conversation persistence** - Resume previous conversations seamlessly
- 📝 **Full conversation logs** - Both user and assistant messages saved with timestamps
- 🔄 **Auto-resume** - Lists your 5 most recent conversations on startup
- 🏷️ **Model tracking** - Automatically saves and loads the model used per conversation
- 🚀 **Simple and fast** - Minimal setup, quick startup - 🚀 **Simple and fast** - Minimal setup, quick startup
- 📝 **Line editing** - Full cursor movement and text editing support
## Prerequisites ## Prerequisites
@ -19,26 +21,20 @@ A simple, colorful terminal-based messenger application written in Go that conne
1. **Clone or download the project** 1. **Clone or download the project**
2. **Initialize Go module** 2. **Install dependencies**
```bash
go mod init messenger
```
3. **Install dependencies**
```bash ```bash
go get github.com/chzyer/readline go get github.com/chzyer/readline
``` ```
4. **Build the application (optional)** 3. **Build the application (optional)**
```bash ```bash
go build -o messenger main.go go build -o messenger main.go
``` ```
## Usage ## Usage
### Running the application ### Starting the application
**Using go run:** **Using go run:**
@ -52,13 +48,29 @@ go run main.go
./messenger ./messenger
``` ```
### Getting started ### First time usage
1. When prompted, enter a model name (or press Enter for "default") 1. Press 'n' or Enter when asked about continuing a conversation
2. Start typing your messages 2. Enter a model name (or press Enter for "default")
3. The assistant will respond to each message 3. Start chatting with the assistant
4. Use arrow keys to navigate through your input history 4. Type `quit` or `exit` to end the session
5. Type `quit` or `exit` to end the session
### Resuming a conversation
1. When prompted, you'll see your recent conversations:
```
Recent conversations:
1. resp_d2925cd3-d99c-46f6-a70a-05b1453b8d4f (model: gpt-4)
2. resp_a1234567-89ab-cdef-0123-456789abcdef (model: default)
Continue? (enter number or 'n' for new):
```
2. Enter the number (1-5) to resume that conversation
3. The last 10 message exchanges will be displayed
4. The model name is automatically loaded
5. Continue chatting from where you left off
### Keyboard shortcuts ### Keyboard shortcuts
@ -67,9 +79,26 @@ go run main.go
- **←/→ (Left/Right arrows)** - Move cursor within current input - **←/→ (Left/Right arrows)** - Move cursor within current input
- **Ctrl+A** - Jump to beginning of line - **Ctrl+A** - Jump to beginning of line
- **Ctrl+E** - Jump to end of line - **Ctrl+E** - Jump to end of line
- **Ctrl+C** - Interrupt/exit application - **Ctrl+C** or type `quit`/`exit` - Exit application
## Configuration ## How It Works
### Conversation Files
For each conversation, three files are created in `/tmp/`:
1. **`messenger_{ID}.tmp`** - Readline history for arrow key navigation (user inputs only)
2. **`messenger_{ID}_log.txt`** - Full conversation log with timestamps (both user and assistant)
3. **`messenger_{ID}_meta.txt`** - Stores the model name for the conversation
### Conversation Continuity
- Each response from the API includes a unique `id`
- This ID is sent as `previous_response_id` in subsequent requests
- This maintains conversation context across the entire session
- When resuming, the conversation ID is loaded and context is preserved
## API Configuration
The application connects to: The application connects to:
@ -81,8 +110,6 @@ To change the endpoint, modify the `apiURL` constant in `main.go`.
### Request format ### Request format
The application sends POST requests with the following JSON structure:
```json ```json
{ {
"input": "your message here", "input": "your message here",
@ -96,8 +123,6 @@ The application sends POST requests with the following JSON structure:
### Response format ### Response format
Expected response structure:
```json ```json
{ {
"id": "response-id", "id": "response-id",
@ -126,7 +151,7 @@ Expected response structure:
``` ```
. .
├── main.go # Main application file ├── main.go # Main application file (~160 lines)
├── go.mod # Go module file ├── go.mod # Go module file
└── README.md # This file └── README.md # This file
``` ```
@ -151,7 +176,11 @@ go mod tidy
### History file permissions ### History file permissions
The app creates a history file at `/tmp/messenger_history.tmp`. If you encounter permission errors, ensure you have write access to `/tmp/`. The app creates files in `/tmp/`. If you encounter permission errors, ensure you have write access to `/tmp/`.
### Cannot find previous conversations
Conversations are stored in `/tmp/` which may be cleared on system restart. For permanent storage, modify the file paths in `main.go` to use a different directory like `~/.messenger/`.
## Dependencies ## Dependencies

View File

@ -6,6 +6,9 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"path/filepath"
"sort"
"strings" "strings"
"time" "time"
@ -43,14 +46,7 @@ type Messenger struct {
} }
func (m *Messenger) Send(input string) (string, error) { func (m *Messenger) Send(input string) (string, error) {
reqBody := Request{ data, _ := json.Marshal(Request{input, m.model, m.prevID, map[string]string{"channel": "text"}})
Input: input,
Model: m.model,
PreviousResponseID: m.prevID,
Metadata: map[string]string{"channel": "text"},
}
data, _ := json.Marshal(reqBody)
resp, err := m.client.Post(apiURL, "application/json", bytes.NewBuffer(data)) resp, err := m.client.Post(apiURL, "application/json", bytes.NewBuffer(data))
if err != nil { if err != nil {
return "", err return "", err
@ -58,65 +54,152 @@ func (m *Messenger) Send(input string) (string, error) {
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
var response Response var r Response
if err := json.Unmarshal(body, &response); err != nil { json.Unmarshal(body, &r)
return "", err m.prevID = r.ID
if r.Status == "completed" && len(r.Output) > 0 && len(r.Output[0].Content) > 0 {
return r.Output[0].Content[0].Text, nil
} }
return "", fmt.Errorf("invalid response")
}
m.prevID = response.ID func getConversations() []string {
files, _ := filepath.Glob("/tmp/messenger_*_log.txt")
if response.Status != "completed" || len(response.Output) == 0 || len(response.Output[0].Content) == 0 { type f struct {
return "", fmt.Errorf("invalid response") id string
t time.Time
} }
var list []f
for _, file := range files {
info, _ := os.Stat(file)
id := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(file), "messenger_"), "_log.txt")
list = append(list, f{id, info.ModTime()})
}
sort.Slice(list, func(i, j int) bool { return list[i].t.After(list[j].t) })
return response.Output[0].Content[0].Text, nil var ids []string
for i, item := range list {
if i >= 5 {
break
}
ids = append(ids, item.id)
}
return ids
}
func readFile(path string) string {
data, _ := os.ReadFile(path)
return strings.TrimSpace(string(data))
}
func writeFile(path, content string) {
os.WriteFile(path, []byte(content), 0644)
}
func appendFile(path, content string) {
f, _ := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
defer f.Close()
f.WriteString(content)
}
func showHistory(id string) {
lines := strings.Split(readFile(fmt.Sprintf("/tmp/messenger_%s_log.txt", id)), "\n")
start := len(lines) - 20
if start < 0 {
start = 0
}
fmt.Println("\n--- Previous Messages ---")
for _, line := range lines[start:] {
if strings.Contains(line, "You: ") {
fmt.Printf(blue+"You: %s"+reset+"\n", strings.SplitN(line, "You: ", 2)[1])
} else if strings.Contains(line, "Assistant: ") {
fmt.Printf(green+"Assistant: %s"+reset+"\n", strings.SplitN(line, "Assistant: ", 2)[1])
}
}
fmt.Println("-------------------------\n")
} }
func main() { func main() {
fmt.Print("Enter model name (default: 'default'): ") var id, model string
var model string
fmt.Scanln(&model) // Check for recent conversations
if model == "" { convs := getConversations()
model = "default" if len(convs) > 0 {
fmt.Println("Recent conversations:")
for i, cid := range convs {
m := readFile(fmt.Sprintf("/tmp/messenger_%s_meta.txt", cid))
fmt.Printf("%d. %s (model: %s)\n", i+1, cid, m)
}
fmt.Print("\nContinue? (enter number or 'n' for new): ")
var choice string
fmt.Scanln(&choice)
var num int
if _, err := fmt.Sscanf(choice, "%d", &num); err == nil && num >= 1 && num <= len(convs) {
id = convs[num-1]
model = readFile(fmt.Sprintf("/tmp/messenger_%s_meta.txt", id))
fmt.Printf("Continuing: %s (model: %s)\n", id, model)
showHistory(id)
}
} }
messenger := &Messenger{ if model == "" {
client: &http.Client{Timeout: 10 * time.Second}, fmt.Print("Enter model name (default: 'default'): ")
model: model, fmt.Scanln(&model)
if model == "" {
model = "default"
}
} }
m := &Messenger{&http.Client{Timeout: 10 * time.Second}, model, id}
fmt.Println("\n=== Terminal Messenger ===") fmt.Println("\n=== Terminal Messenger ===")
fmt.Println("Use ↑/↓ arrows for history, type 'quit' to exit\n") fmt.Println("Use ↑/↓ for history, 'quit' to exit\n")
rl, err := readline.NewEx(&readline.Config{ histFile := "/tmp/messenger_temp.tmp"
Prompt: blue + "You: " + reset, if id != "" {
HistoryFile: "/tmp/messenger_history.tmp", histFile = fmt.Sprintf("/tmp/messenger_%s.tmp", id)
})
if err != nil {
fmt.Println("Error:", err)
return
} }
rl, _ := readline.NewEx(&readline.Config{Prompt: blue + "You: " + reset, HistoryFile: histFile})
defer rl.Close() defer rl.Close()
first := id == ""
for { for {
input, err := rl.Readline() input, err := rl.Readline()
if err != nil || strings.TrimSpace(input) == "quit" || strings.TrimSpace(input) == "exit" { if err != nil || input == "quit" || input == "exit" {
break break
} }
if input = strings.TrimSpace(input); input == "" {
input = strings.TrimSpace(input)
if input == "" {
continue continue
} }
response, err := messenger.Send(input) response, err := m.Send(input)
if err != nil { if err != nil {
fmt.Println("Error:", err) fmt.Println("Error:", err)
continue continue
} }
if first && m.prevID != "" {
first = false
rl.Close()
rl, _ = readline.NewEx(&readline.Config{
Prompt: blue + "You: " + reset,
HistoryFile: fmt.Sprintf("/tmp/messenger_%s.tmp", m.prevID),
})
writeFile(fmt.Sprintf("/tmp/messenger_%s_meta.txt", m.prevID), m.model)
fmt.Printf("(ID: %s)\n\n", m.prevID)
}
if m.prevID != "" {
ts := time.Now().Format("2006-01-02 15:04:05")
logFile := fmt.Sprintf("/tmp/messenger_%s_log.txt", m.prevID)
appendFile(logFile, fmt.Sprintf("[%s] You: %s\n[%s] Assistant: %s\n", ts, input, ts, response))
}
fmt.Printf(green+"Assistant: %s"+reset+"\n\n", response) fmt.Printf(green+"Assistant: %s"+reset+"\n\n", response)
} }
fmt.Println("Goodbye!") fmt.Println("Goodbye!")
} }