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 } }