diff --git a/README.md b/README.md index 32120cf..0e9a73a 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,10 @@ Goaichat is a terminal-based AI chat client written in Go. It connects to OpenAI ## Features - **Config-driven startup**: Loads defaults from `config.yaml` with environment overrides. -- **Interactive chat loop**: (In progress) command-driven conversation with persistent history. -- **Extensible architecture**: Structured packages under `internal/` for config, app wiring, chat, storage, OpenAI client, UI, and telemetry. +- **Interactive chat loop**: Command-driven conversation with OpenAI-compatible models. +- **Persistent history**: Sessions and messages are stored in SQLite, allowing `/open` and `/rename` commands. +- **Session naming assistance**: The assistant suggests descriptive session names that you can adopt. +- **Extensible architecture**: Structured packages under `internal/` for configuration, chat orchestration, storage, OpenAI client integration, and CLI wiring. ## Getting Started @@ -31,7 +33,7 @@ go build ./cmd/goaichat ### Configuration -Create `config.yaml` in the project root or pass `--config` to specify a path. A minimal configuration looks like: +Create `config.yaml` in the project root or pass `--config` to specify a path. When no path is provided, Goaichat searches the current working directory first and then `$HOME/.config/goaichat/config.yaml`. A minimal configuration looks like: ```yaml api: @@ -58,7 +60,24 @@ Environment variables override several fields: go run ./cmd/goaichat --config path/to/config.yaml ``` -The current build logs startup/shutdown messages while core services are implemented. Upcoming milestones include the chat loop, persistence, OpenAI integration, and Bubble Tea UI. +### Usage + +After launching, type messages at the prompt. The following commands are available: + +- `/help` – display available commands. +- `/reset` – clear the current in-memory conversation. +- `/list` – list saved sessions with summaries. +- `/open ` – load a previously saved session by name. +- `/rename ` – assign a new name to the current session (use suggestions or provide your own). +- `/exit` – quit the application. + +The assistant proposes session name suggestions after the first reply in a session. Use `/rename ` to accept them quickly. + +### Persistence + +Goaichat uses SQLite for storage. On first run, the application applies migrations from `internal/storage/migrations/`. Sessions are automatically created when you begin chatting, and every message is stored for later retrieval. + +By default the database is stored at `$HOME/.local/share/goaichat/goaichat.db`. You can override the location via the `storage.path` or `storage.base_dir` settings in `config.yaml`. ## Development @@ -67,4 +86,4 @@ The current build logs startup/shutdown messages while core services are impleme ## License -TBD +This project is licensed under the [MIT License](LICENSE). diff --git a/dev-plan.md b/dev-plan.md index f787e9b..4ae42db 100644 --- a/dev-plan.md +++ b/dev-plan.md @@ -8,7 +8,7 @@ ## Functional Requirements - **Interactive chat loop** Allow continuous message exchange with the AI until the user exits. - **Config-driven startup** Parse `config.yaml` for `api.url`, `api.key`, model defaults, and future options (e.g., system prompts, temperature, streaming flag). -- **Persistent history** Store chats in a SQLite database located at `/var/log/goaichat//goaichat.db`, capturing session metadata and full message transcripts. +- **Persistent history** Store chats in a SQLite database located at `$HOME/.local/share/goaichat/goaichat.db` (configurable), capturing session metadata and full message transcripts. - **History commands** Provide `/list` to enumerate saved chats and allow selecting or reopening them within the session. - **Model management** Support `/models` command to display configured/available models, persist the user’s choice, and use it for subsequent requests. - **Graceful exit controls** Support commands like `/exit`, `/reset`, `/help`. @@ -44,20 +44,17 @@ dflow LR ChatCore --> UI Config[(config.yaml + env)] --> ConfigPkg[Configuration] ConfigPkg --> AppCore[Application core] - AppCore --> UI AppCore --> ChatCore AppCore --> OpenAIClient AppCore --> Storage -``` - -## Persistence & Data Storage (`internal/storage`) -- **Data directory** Resolve user-specific path `/var/log/goaichat//` (configurable override) and ensure directories exist with secure permissions. -- **Database** SQLite file `goaichat.db` managed via `modernc.org/sqlite` (pure Go) for portability; allow build tag to switch to `mattn/go-sqlite3` if desired. -- **Schema** - - **`sessions`** (`id`, `name`, `created_at`, `updated_at`, `model_name`, `summary`). - - **`messages`** (`id`, `session_id`, `role`, `content`, `token_count`, `created_at`). - - **`models`** (`id`, `name`, `display_name`, `provider`, `is_default`, `last_used_at`). -- **Migrations** Ship SQL migration files applied on startup; version using goose/atlas or simple in-app migration runner. +##- **Persistence & Data Storage (`internal/storage`) + - **Data directory** Resolve user-specific path `$HOME/.local/share/goaichat/` (configurable override) and ensure directories exist with secure permissions. + - **Database** SQLite file `goaichat.db` managed via `modernc.org/sqlite` (pure Go) for portability; allow build tag to switch to `mattn/go-sqlite3` if desired. + - **Schema** + - **`sessions`** (`id`, `name`, `created_at`, `updated_at`, `model_name`, `summary`). + - **`messages`** (`id`, `session_id`, `role`, `content`, `token_count`, `created_at`). + - **`models`** (`id`, `name`, `display_name`, `provider`, `is_default`, `last_used_at`). +{{ ... }} - **Data access** Provide repository interfaces for chat service to create sessions, append messages, list chats, update selected model, and fetch transcripts. - **Performance** Enable write-ahead logging, tune connection settings, and guard concurrent access with a request queue or mutex. @@ -127,7 +124,7 @@ logging: - **Milestone 4: Persistence foundation** - **Design** SQLite schema and migrations within `internal/storage`. - **Implement** repositories for sessions, messages, and models. - - **Ensure** path resolution to `/var/log/goaichat//` with permission checks and tests. + - **Ensure** path resolution to `$HOME/.local/share/goaichat/` with permission checks and tests. - **Milestone 5: Chat loop MVP** - **Implement** CLI loop that reads user input, calls chat client, prints responses. diff --git a/internal/config/config.go b/internal/config/config.go index 2c62aaa..0642cef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "gopkg.in/yaml.v3" @@ -63,8 +64,19 @@ func Load(path string) (*Config, error) { return nil, err } } else { - if err := loadFile("config.yaml", &cfg); err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, err + candidates := []string{"config.yaml"} + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + candidates = append(candidates, filepath.Join(home, ".config", "goaichat", "config.yaml")) + } + + for _, candidate := range candidates { + if err := loadFile(candidate, &cfg); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return nil, err + } + break } } diff --git a/internal/openai/client.go b/internal/openai/client.go index 355ec77..739c2c9 100644 --- a/internal/openai/client.go +++ b/internal/openai/client.go @@ -1,6 +1,7 @@ package openai import ( + "bufio" "bytes" "context" "encoding/json" @@ -86,6 +87,9 @@ func (c *Client) CreateChatCompletion(ctx context.Context, req ChatCompletionReq defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { + if req.Stream { + return decodeStream(resp.Body) + } return decodeSuccess(resp.Body) } @@ -93,13 +97,121 @@ func (c *Client) CreateChatCompletion(ctx context.Context, req ChatCompletionReq } func decodeSuccess(r io.Reader) (*ChatCompletionResponse, error) { - var response ChatCompletionResponse - if err := json.NewDecoder(r).Decode(&response); err != nil { - return nil, fmt.Errorf("decode response: %w", err) + data, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) } + + var response ChatCompletionResponse + if err := json.Unmarshal(data, &response); err != nil { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return nil, fmt.Errorf("decode response: %w", err) + } + return &ChatCompletionResponse{ + Choices: []ChatCompletionChoice{{ + Message: ChatMessage{Role: "assistant", Content: string(trimmed)}, + }}, + }, nil + } + return &response, nil } +func decodeStream(r io.Reader) (*ChatCompletionResponse, error) { + scanner := bufio.NewScanner(r) + var payloads []json.RawMessage + + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "data: ") { + payload := strings.TrimPrefix(line, "data: ") + if payload == "[DONE]" { + break + } + payloads = append(payloads, json.RawMessage(payload)) + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read stream: %w", err) + } + + if len(payloads) == 0 { + return nil, errors.New("empty stream response") + } + + type streamChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Choices []struct { + Index int `json:"index"` + Message ChatMessage `json:"message"` + Delta ChatMessage `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage Usage `json:"usage"` + } + + var aggregated ChatCompletionResponse + var builder strings.Builder + role := "assistant" + finish := "" + + for _, payload := range payloads { + var chunk streamChunk + if err := json.Unmarshal(payload, &chunk); err != nil { + return nil, fmt.Errorf("decode stream response: %w", err) + } + if aggregated.ID == "" { + aggregated.ID = chunk.ID + } + if aggregated.Object == "" { + aggregated.Object = chunk.Object + } + aggregated.Usage.PromptTokens += chunk.Usage.PromptTokens + aggregated.Usage.CompletionTokens += chunk.Usage.CompletionTokens + aggregated.Usage.TotalTokens += chunk.Usage.TotalTokens + + if len(chunk.Choices) == 0 { + continue + } + + choice := chunk.Choices[0] + if choice.Message.Role != "" { + role = choice.Message.Role + } + if choice.Delta.Role != "" { + role = choice.Delta.Role + } + if choice.Message.Content != "" { + builder.WriteString(choice.Message.Content) + } + if choice.Delta.Content != "" { + builder.WriteString(choice.Delta.Content) + } + if choice.FinishReason != "" { + finish = choice.FinishReason + } + } + + content := strings.TrimSpace(builder.String()) + if content == "" { + return nil, errors.New("stream response contained no content") + } + + aggregated.Choices = []ChatCompletionChoice{{ + Index: 0, + FinishReason: finish, + Message: ChatMessage{ + Role: role, + Content: content, + }, + }} + + return &aggregated, nil +} + func decodeError(r io.Reader, status int) error { var apiErr ErrorResponse if err := json.NewDecoder(r).Decode(&apiErr); err != nil { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 77d1d36..f047fc1 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -87,20 +87,26 @@ func resolvePath(cfg config.StorageConfig) (string, error) { return cfg.Path, nil } - username := cfg.Username - if username == "" { - username = os.Getenv("USER") - if username == "" { - username = "default" + baseDir := strings.TrimSpace(cfg.BaseDir) + if baseDir == "" { + home, err := os.UserHomeDir() + if err == nil && strings.TrimSpace(home) != "" { + baseDir = filepath.Join(home, ".local", "share", "goaichat") } } - base := filepath.Join("/var/log/goaichat", username) - if cfg.BaseDir != "" { - base = cfg.BaseDir + if baseDir == "" { + username := strings.TrimSpace(cfg.Username) + if username == "" { + username = strings.TrimSpace(os.Getenv("USER")) + if username == "" { + username = "default" + } + } + baseDir = filepath.Join(os.TempDir(), "goaichat", username) } - return filepath.Join(base, "goaichat.db"), nil + return filepath.Join(baseDir, "goaichat.db"), nil } func ensureDir(dir string) error {