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 status string streamBuffer strings.Builder } // 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) a.setStatus("Type your message. Use /exit to quit, /reset to clear history.") for { if err := a.renderUI(); err != nil { return err } if !scanner.Scan() { if err := scanner.Err(); err != nil { return err } return nil } input := scanner.Text() a.setStatus("") handled, exit, err := a.handleCommand(ctx, input) if err != nil { a.setStatus("Command error: %v", err) continue } if handled { if exit { return nil } continue } streamEnabled := a.chat != nil && a.chat.StreamingEnabled() a.clearStreamingContent() var handler openai.ChatCompletionStreamHandler if streamEnabled { handler = func(event openai.ChatCompletionStreamEvent) error { if event.Done || event.Content == "" { return nil } a.appendStreamingContent(event.Content) return nil } } if _, err := a.chat.Send(ctx, input, handler); err != nil { a.setStatus("Error: %v", err) continue } a.clearStreamingContent() a.setStatus("") 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 { a.setStatus("Usage: /rename ") return true, false, nil } rawName := strings.Join(parts[1:], " ") normalized := chat.NormalizeSessionName(rawName) if normalized == "" { a.setStatus("Session name cannot be empty.") return true, false, nil } if a.repo != nil { existing, fetchErr := a.repo.GetSessionByName(ctx, normalized) if fetchErr != nil { a.setStatus("Failed to verify name availability: %v", fetchErr) return true, false, nil } if existing != nil { currentID := a.chat.CurrentSessionID() if currentID == 0 || existing.ID != currentID { a.setStatus("Session name %q is already in use.", normalized) return true, false, nil } } } applied, setErr := a.chat.SetSessionName(ctx, rawName) if setErr != nil { a.setStatus("Failed to rename session: %v", setErr) return true, false, nil } a.setStatus("Session renamed to %q.", applied) return true, false, nil } if strings.HasPrefix(trimmed, "/open") { parts := strings.Fields(trimmed) if len(parts) != 2 { a.setStatus("Usage: /open ") return true, false, nil } if a.repo == nil { a.setStatus("Storage not initialised; cannot open sessions.") return true, false, nil } session, fetchErr := a.repo.GetSessionByName(ctx, parts[1]) if fetchErr != nil { a.setStatus("Failed to fetch session: %v", fetchErr) return true, false, nil } if session == nil { a.setStatus("Session %q not found.", parts[1]) return true, false, nil } messages, msgErr := a.repo.GetMessages(ctx, session.ID) if msgErr != nil { a.setStatus("Failed to load messages: %v", msgErr) return true, false, nil } 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) a.setStatus("Loaded session %s with %d messages.", session.Name, len(chatMessages)) return true, false, nil } switch trimmed { case "/exit": return true, true, nil case "/reset": a.chat.Reset() a.setStatus("History cleared.") return true, false, nil case "/list": if a.repo == nil { a.setStatus("History commands unavailable (storage not initialised).") return true, false, nil } sessions, listErr := a.repo.ListSessions(ctx) if listErr != nil { a.setStatus("Failed to list sessions: %v", listErr) return true, false, nil } if len(sessions) == 0 { a.setStatus("No saved sessions.") return true, false, nil } var lines []string for _, session := range sessions { summary := session.Summary.String if summary == "" { summary = "(no summary)" } lines = append(lines, fmt.Sprintf("- %s [%s]: %s", session.Name, session.ModelName, summary)) } a.setStatus("%s", strings.Join(lines, "\n")) return true, false, nil case "/help": a.setStatus("Commands: /exit, /reset, /list, /open , /rename , /help (more coming soon)") return true, false, nil default: a.setStatus("Unknown command %q. Try /help.", trimmed) return true, false, nil } } 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() a.setStatus("Suggested session name: %s\nUse /rename %s to apply it now.", suggestion, suggestion) return nil } func (a *App) setStatus(msg string, args ...any) { if a == nil { return } if msg == "" { a.status = "" return } if len(args) == 0 { a.status = msg return } a.status = fmt.Sprintf(msg, args...) } func (a *App) renderUI() error { if a == nil { return errors.New("app is nil") } if _, err := fmt.Fprint(a.output, "\033[2J\033[H"); err != nil { return err } sessionName := "(unnamed session)" if a.chat != nil && strings.TrimSpace(a.chat.SessionName()) != "" { sessionName = a.chat.SessionName() } title := fmt.Sprintf("goaichat - %s", sessionName) underline := strings.Repeat("=", len(title)) if _, err := fmt.Fprintf(a.output, "%s\n%s\n\n", title, underline); err != nil { return err } if _, err := fmt.Fprintln(a.output, "Conversation"); err != nil { return err } if _, err := fmt.Fprintln(a.output, strings.Repeat("-", len("Conversation"))); err != nil { return err } if a.chat != nil { history := a.chat.History() for _, msg := range history { label := roleLabel(msg.Role) if _, err := fmt.Fprintf(a.output, "%s: %s\n", label, msg.Content); err != nil { return err } } } if a.streamBuffer.Len() > 0 { if _, err := fmt.Fprintf(a.output, "AI: %s\n", a.streamBuffer.String()); err != nil { return err } } if _, err := fmt.Fprintln(a.output); err != nil { return err } if status := strings.TrimSpace(a.status); status != "" { if _, err := fmt.Fprintln(a.output, "Status"); err != nil { return err } if _, err := fmt.Fprintln(a.output, strings.Repeat("-", len("Status"))); err != nil { return err } if _, err := fmt.Fprintf(a.output, "%s\n\n", status); err != nil { return err } } if _, err := fmt.Fprint(a.output, "> "); err != nil { return err } return nil } func roleLabel(role string) string { switch role { case "assistant": return "AI" case "user": return "You" case "system": return "System" default: if strings.TrimSpace(role) == "" { return "Unknown" } return strings.Title(role) } } func (a *App) appendStreamingContent(chunk string) { if a == nil || chunk == "" { return } a.streamBuffer.WriteString(chunk) if err := a.renderUI(); err != nil && a.logger != nil { a.logger.Warn("stream render failed", "error", err) } } func (a *App) clearStreamingContent() { if a == nil { return } a.streamBuffer.Reset() }