Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions cmd/chat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package cmd

import (
"bufio"
"encoding/json"
"fmt"
"io"
"regexp"
"strings"
"time"

"github.com/briandowns/spinner"
"github.com/scalvert/glean-cli/pkg/config"
"github.com/scalvert/glean-cli/pkg/http"
"github.com/spf13/cobra"
)

type ChatMessage struct {
Author string `json:"author"`
MessageType string `json:"messageType"`
Fragments []struct {
Text string `json:"text"`
} `json:"fragments"`
}

type ChatRequest struct {
AgentConfig AgentConfig `json:"agentConfig"`
ApplicationID string `json:"applicationId,omitempty"`
ChatID string `json:"chatId,omitempty"`
Messages []ChatMessage `json:"messages"`
TimeoutMillis int `json:"timeoutMillis"`
Stream bool `json:"stream"`
SaveChat bool `json:"saveChat"`
}

type AgentConfig struct {
Agent string `json:"agent"`
Mode string `json:"mode"`
}

type ChatResponse struct {
ChatSessionTrackingToken string `json:"chatSessionTrackingToken"`
Messages []struct {
Author string `json:"author"`
Fragments []struct {
Text string `json:"text"`
} `json:"fragments"`
HasMoreFragments bool `json:"hasMoreFragments,omitempty"`
} `json:"messages"`
}

// cleanMarkdown removes markdown formatting from text
func cleanMarkdown(text string) string {
// Remove bold/italic markers
text = regexp.MustCompile(`\*\*`).ReplaceAllString(text, "")
text = regexp.MustCompile(`\*`).ReplaceAllString(text, "")
text = regexp.MustCompile(`__`).ReplaceAllString(text, "")
text = regexp.MustCompile(`_`).ReplaceAllString(text, "")

// Convert markdown links to plain text
text = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(text, "$1")

// Remove backticks
text = regexp.MustCompile("`").ReplaceAllString(text, "")

// Remove multiple blank lines
text = regexp.MustCompile(`\n\s*\n\s*\n`).ReplaceAllString(text, "\n\n")

return text
}

func NewCmdChat() *cobra.Command {
var timeoutMillis int
var saveChat bool

cmd := &cobra.Command{
Use: "chat [message]",
Short: "Have a conversation with Glean's chat API",
Long: `Have a conversation with Glean's chat API.

The chat API allows you to have natural language conversations with Glean's AI.
The response will be streamed as it becomes available.

Example:
glean chat "What are the company holidays?"
glean chat --timeout 60000 "Tell me about the engineering team"`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return executeChat(cmd, args[0], timeoutMillis, saveChat)
},
}

cmd.Flags().IntVar(&timeoutMillis, "timeout", 30000, "Request timeout in milliseconds")
cmd.Flags().BoolVar(&saveChat, "save", true, "Save the chat for later continuation")

return cmd
}

func executeChat(cmd *cobra.Command, question string, timeoutMillis int, saveChat bool) error {
cfg, err := config.LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

client, err := http.NewClient(cfg)
if err != nil {
return err
}

// Create chat request
req := ChatRequest{
Messages: []ChatMessage{
{
Author: "USER",
MessageType: "CONTENT",
Fragments: []struct {
Text string `json:"text"`
}{
{Text: question},
},
},
},
Stream: true,
AgentConfig: AgentConfig{
Agent: "DEFAULT",
Mode: "DEFAULT",
},
SaveChat: saveChat,
TimeoutMillis: timeoutMillis,
}

// Convert request to JSON
jsonBytes, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("error marshaling request: %w", err)
}

// Create HTTP request
httpReq := &http.Request{
Method: "POST",
Path: "chat",
Body: json.RawMessage(jsonBytes),
Stream: true,
}

// Start spinner
spin := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
spin.Prefix = "Waiting for response "
spin.Start()
defer spin.Stop()

// Send request and get streaming response
responseBody, err := client.SendStreamingRequest(httpReq)
if err != nil {
return err
}
defer responseBody.Close()

// Create a reader for the streaming response
reader := bufio.NewReader(responseBody)
firstLine := true

// Read response line by line
for {
line, err := reader.ReadString('\n')
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("error reading response: %w", err)
}

// Skip empty lines
line = strings.TrimSpace(line)
if line == "" {
continue
}

// Stop spinner after first line
if firstLine {
spin.Stop()
firstLine = false
}

// Parse and print the response
var chatResp ChatResponse
if err := json.Unmarshal([]byte(line), &chatResp); err != nil {
return fmt.Errorf("error parsing response line: %w", err)
}

// Print each message
for _, msg := range chatResp.Messages {
for _, fragment := range msg.Fragments {
cleanedText := cleanMarkdown(fragment.Text)
if cleanedText != "" {
fmt.Fprint(cmd.OutOrStdout(), cleanedText)
if !msg.HasMoreFragments {
fmt.Fprintln(cmd.OutOrStdout())
}
}
}
}
}

return nil
}
112 changes: 112 additions & 0 deletions cmd/chat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package cmd

import (
"bytes"
"testing"

"github.com/scalvert/glean-cli/pkg/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestChatCommand(t *testing.T) {
t.Run("basic chat response", func(t *testing.T) {
// Create a response with multiple streaming messages
response := []byte(`{"messages":[{"author":"GLEAN_AI","fragments":[{"text":"Hello"}],"hasMoreFragments":false}],"chatSessionTrackingToken":"token1"}
{"messages":[{"author":"GLEAN_AI","fragments":[{"text":"How"}],"hasMoreFragments":true}],"chatSessionTrackingToken":"token2"}
{"messages":[{"author":"GLEAN_AI","fragments":[{"text":" can"}],"hasMoreFragments":true}],"chatSessionTrackingToken":"token3"}
{"messages":[{"author":"GLEAN_AI","fragments":[{"text":" I"}],"hasMoreFragments":true}],"chatSessionTrackingToken":"token4"}
{"messages":[{"author":"GLEAN_AI","fragments":[{"text":" help?"}],"hasMoreFragments":false}],"chatSessionTrackingToken":"token5"}`)

_, cleanup := testutils.SetupTestWithResponse(t, response)
defer cleanup()

b := bytes.NewBufferString("")
cmd := NewCmdChat()
cmd.SetOut(b)
cmd.SetArgs([]string{"What can you do?"})

err := cmd.Execute()
require.NoError(t, err)

output := b.String()
assert.Contains(t, output, "Hello")
assert.Contains(t, output, "How can I help?")
})

t.Run("chat with error response", func(t *testing.T) {
// Create an error response that's not in the expected ChatResponse format
response := []byte(`{"error": "Something went wrong"}\n`)
_, cleanup := testutils.SetupTestWithResponse(t, response)
defer cleanup()

b := bytes.NewBufferString("")
cmd := NewCmdChat()
cmd.SetOut(b)
cmd.SetArgs([]string{"Test error"})

err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "error parsing response line")
})

t.Run("chat with invalid JSON response", func(t *testing.T) {
response := []byte(`invalid json`)
_, cleanup := testutils.SetupTestWithResponse(t, response)
defer cleanup()

b := bytes.NewBufferString("")
cmd := NewCmdChat()
cmd.SetOut(b)
cmd.SetArgs([]string{"Test invalid"})

err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "error parsing response line")
})

t.Run("chat with empty response", func(t *testing.T) {
response := []byte(``)
_, cleanup := testutils.SetupTestWithResponse(t, response)
defer cleanup()

b := bytes.NewBufferString("")
cmd := NewCmdChat()
cmd.SetOut(b)
cmd.SetArgs([]string{"Test empty"})

err := cmd.Execute()
require.NoError(t, err)
assert.Empty(t, b.String())
})

t.Run("chat with timeout flag", func(t *testing.T) {
response := []byte(`{"messages":[{"author":"GLEAN_AI","fragments":[{"text":"Quick response"}],"hasMoreFragments":false}],"chatSessionTrackingToken":"token1"}`)
_, cleanup := testutils.SetupTestWithResponse(t, response)
defer cleanup()

b := bytes.NewBufferString("")
cmd := NewCmdChat()
cmd.SetOut(b)
cmd.SetArgs([]string{"--timeout", "60000", "Test timeout"})

err := cmd.Execute()
require.NoError(t, err)
assert.Contains(t, b.String(), "Quick response")
})

t.Run("chat with save flag disabled", func(t *testing.T) {
response := []byte(`{"messages":[{"author":"GLEAN_AI","fragments":[{"text":"Not saved"}],"hasMoreFragments":false}],"chatSessionTrackingToken":"token1"}`)
_, cleanup := testutils.SetupTestWithResponse(t, response)
defer cleanup()

b := bytes.NewBufferString("")
cmd := NewCmdChat()
cmd.SetOut(b)
cmd.SetArgs([]string{"--save=false", "Test no save"})

err := cmd.Execute()
require.NoError(t, err)
assert.Contains(t, b.String(), "Not saved")
})
}
4 changes: 4 additions & 0 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,9 @@ func NewCmdGenerate() *cobra.Command {
}
},
}

// Add subcommands
cmd.AddCommand(NewCmdOpenAPISpec())

return cmd
}
18 changes: 16 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,27 @@ func NewCmdRoot() *cobra.Command {
fmt.Fprintf(os.Stderr, "Error displaying help: %v\n", err)
}
},
// Silence usage output when an error occurs
SilenceUsage: true,
// Ensure consistent error formatting
SilenceErrors: true,
}

// Add all subcommands
cmd.AddCommand(
NewCmdAPI(),
NewCmdConfig(),
NewCmdGenerate(),
NewCmdOpenAPISpec(),
NewCmdSearch(),
NewCmdChat(),
)

// Propagate settings to all subcommands
for _, subCmd := range cmd.Commands() {
subCmd.SilenceUsage = true
subCmd.SilenceErrors = true
}

return cmd
}

Expand All @@ -44,5 +54,9 @@ func init() {
}

func Execute() error {
return rootCmd.Execute()
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return err
}
return nil
}
Loading
Loading