Files
goaichat/internal/app/app.go

194 lines
3.7 KiB
Go

package app
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"strings"
"github.com/stig/goaichat/internal/chat"
"github.com/stig/goaichat/internal/config"
"github.com/stig/goaichat/internal/openai"
)
// App encapsulates the Goaichat application runtime wiring.
type App struct {
logger *slog.Logger
config *config.Config
openAI *openai.Client
chat *chat.Service
input io.Reader
output io.Writer
}
// New constructs a new App instance.
func New(logger *slog.Logger, cfg *config.Config, opts ...Option) *App {
app := &App{
logger: logger,
config: cfg,
input: os.Stdin,
output: os.Stdout,
}
for _, opt := range opts {
opt(app)
}
if app.input == nil {
app.input = os.Stdin
}
if app.output == nil {
app.output = os.Stdout
}
return app
}
// Option configures an App instance.
type Option func(*App)
// WithIO overrides the input/output streams, primarily for testing.
func WithIO(in io.Reader, out io.Writer) Option {
return func(a *App) {
a.input = in
a.output = out
}
}
// Run starts the application lifecycle.
func (a *App) Run(ctx context.Context) error {
if a == nil {
return errors.New("app is nil")
}
if ctx == nil {
return errors.New("context is nil")
}
if err := a.initOpenAIClient(); err != nil {
return err
}
if err := a.initChatService(); err != nil {
return err
}
a.logger.InfoContext(ctx, "starting goaichat", "model", a.config.Model.Name, "api_url", a.config.API.URL)
if err := a.runCLILoop(ctx); err != nil {
return err
}
a.logger.InfoContext(ctx, "goaichat shutdown complete")
return nil
}
func (a *App) initOpenAIClient() error {
if a.openAI != nil {
return nil
}
client, err := openai.NewClient(
a.config.API.Key,
openai.WithBaseURL(a.config.API.URL),
)
if err != nil {
return err
}
a.openAI = client
return nil
}
func (a *App) initChatService() error {
if a.chat != nil {
return nil
}
service, err := chat.NewService(a.logger.With("component", "chat"), a.config.Model, a.openAI)
if err != nil {
return err
}
a.chat = service
return nil
}
func (a *App) runCLILoop(ctx context.Context) error {
scanner := bufio.NewScanner(a.input)
if _, err := fmt.Fprintln(a.output, "Type your message. Use /exit to quit, /reset to clear history."); err != nil {
return err
}
for {
if _, err := fmt.Fprint(a.output, "> "); err != nil {
return err
}
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return err
}
return nil
}
input := scanner.Text()
handled, exit, err := a.handleCommand(ctx, input)
if err != nil {
if _, writeErr := fmt.Fprintf(a.output, "Command error: %v\n", err); writeErr != nil {
return writeErr
}
continue
}
if handled {
if exit {
return nil
}
continue
}
reply, err := a.chat.Send(ctx, input)
if err != nil {
if _, writeErr := fmt.Fprintf(a.output, "Error: %v\n", err); writeErr != nil {
return writeErr
}
continue
}
if _, err := fmt.Fprintf(a.output, "AI: %s\n", reply); err != nil {
return err
}
}
}
func (a *App) handleCommand(ctx context.Context, input string) (handled bool, exit bool, err error) {
_ = ctx
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return true, false, errors.New("no input provided")
}
if !strings.HasPrefix(trimmed, "/") {
return false, false, nil
}
switch trimmed {
case "/exit":
return true, true, nil
case "/reset":
a.chat.Reset()
_, err := fmt.Fprintln(a.output, "History cleared.")
return true, false, err
case "/help":
_, err := fmt.Fprintln(a.output, "Commands: /exit, /reset, /help (more coming soon)")
return true, false, err
default:
_, err := fmt.Fprintf(a.output, "Unknown command %q. Try /help.\n", trimmed)
return true, false, err
}
}