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" "github.com/stig/goaichat/internal/storage" ) // App encapsulates the Goaichat application runtime wiring. type App struct { logger *slog.Logger config *config.Config openAI *openai.Client chat *chat.Service store *storage.Manager repo *storage.Repository 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.initStorage(); 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, a.repo) if err != nil { return err } a.chat = service return nil } func (a *App) initStorage() error { if a.store != nil { return nil } manager, err := storage.NewManager(*a.config) if err != nil { return err } db, err := manager.Open() if err != nil { return err } repo, err := storage.NewRepository(db) if err != nil { _ = manager.Close() return err } a.store = manager a.repo = repo 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 } if err := a.maybeSuggestSessionName(ctx); err != nil { a.logger.WarnContext(ctx, "session name suggestion failed", "error", err) } } } func (a *App) handleCommand(ctx context.Context, input string) (handled bool, exit bool, err error) { trimmed := strings.TrimSpace(input) if trimmed == "" { return true, false, errors.New("no input provided") } if !strings.HasPrefix(trimmed, "/") { return false, false, nil } if strings.HasPrefix(trimmed, "/rename") { parts := strings.Fields(trimmed) if len(parts) < 2 { _, err := fmt.Fprintln(a.output, "Usage: /rename ") return true, false, err } rawName := strings.Join(parts[1:], " ") normalized := chat.NormalizeSessionName(rawName) if normalized == "" { _, err := fmt.Fprintln(a.output, "Session name cannot be empty.") return true, false, err } if a.repo != nil { existing, fetchErr := a.repo.GetSessionByName(ctx, normalized) if fetchErr != nil { _, err := fmt.Fprintf(a.output, "Failed to verify name availability: %v\n", fetchErr) return true, false, err } if existing != nil { currentID := a.chat.CurrentSessionID() if currentID == 0 || existing.ID != currentID { _, err := fmt.Fprintf(a.output, "Session name %q is already in use.\n", normalized) return true, false, err } } } applied, setErr := a.chat.SetSessionName(ctx, rawName) if setErr != nil { _, err := fmt.Fprintf(a.output, "Failed to rename session: %v\n", setErr) return true, false, err } _, err := fmt.Fprintf(a.output, "Session renamed to %q.\n", applied) return true, false, err } if strings.HasPrefix(trimmed, "/open") { parts := strings.Fields(trimmed) if len(parts) != 2 { _, err := fmt.Fprintln(a.output, "Usage: /open ") return true, false, err } if a.repo == nil { _, err := fmt.Fprintln(a.output, "Storage not initialised; cannot open sessions.") return true, false, err } session, fetchErr := a.repo.GetSessionByName(ctx, parts[1]) if fetchErr != nil { _, err := fmt.Fprintf(a.output, "Failed to fetch session: %v\n", fetchErr) return true, false, err } if session == nil { _, err := fmt.Fprintf(a.output, "Session %q not found.\n", parts[1]) return true, false, err } messages, msgErr := a.repo.GetMessages(ctx, session.ID) if msgErr != nil { _, err := fmt.Fprintf(a.output, "Failed to load messages: %v\n", msgErr) return true, false, err } chatMessages := make([]openai.ChatMessage, 0, len(messages)) for _, message := range messages { role := strings.TrimSpace(message.Role) if role == "" { continue } chatMessages = append(chatMessages, openai.ChatMessage{Role: role, Content: message.Content}) } summaryPresent := session.Summary.Valid && strings.TrimSpace(session.Summary.String) != "" a.chat.RestoreSession(session.ID, session.Name, chatMessages, summaryPresent) _, err := fmt.Fprintf(a.output, "Loaded session %q with %d messages.\n", session.Name, len(chatMessages)) return true, false, err } 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 "/list": if a.repo == nil { _, err := fmt.Fprintln(a.output, "History commands unavailable (storage not initialised).") return true, false, err } sessions, listErr := a.repo.ListSessions(ctx) if listErr != nil { _, err := fmt.Fprintf(a.output, "Failed to list sessions: %v\n", listErr) return true, false, err } if len(sessions) == 0 { _, err := fmt.Fprintln(a.output, "No saved sessions.") return true, false, err } for _, session := range sessions { summary := session.Summary.String if summary == "" { summary = "(no summary)" } if _, err := fmt.Fprintf(a.output, "- %s [%s]: %s\n", session.Name, session.ModelName, summary); err != nil { return true, false, err } } return true, false, nil case "/help": _, err := fmt.Fprintln(a.output, "Commands: /exit, /reset, /list, /open , /rename , /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 } } func (a *App) maybeSuggestSessionName(ctx context.Context) error { if a.chat == nil { return nil } if !a.chat.ShouldSuggestSessionName() { return nil } suggestion, err := a.chat.SuggestSessionName(ctx) if err != nil { a.chat.MarkSessionNameSuggested() return err } a.chat.MarkSessionNameSuggested() if _, err := fmt.Fprintf(a.output, "Suggested session name: %s\nUse /rename %s to apply it now.\n", suggestion, suggestion); err != nil { return err } return nil }