|
| 1 | +## Project Overview |
| 2 | + |
| 3 | +HostedGPT is a Ruby on Rails 8.0 app providing a multi-provider conversational AI UI (OpenAI, Anthropic, Groq, Google Gemini, and others via OpenAI-compatible APIs). Users bring their own API keys, create assistants bound to language models, chat with streaming responses, switch models/providers mid-conversation, attach images/files, and use tool/function calling where the provider supports it. |
| 4 | + |
| 5 | +## Commands |
| 6 | + |
| 7 | +### Running locally |
| 8 | +- Outside Docker: `bin/dev` starts Puma, SolidQueue worker, and tailwindcss watcher (via Overmind/Procfile.dev). Do **not** run `db:setup` directly — it won't configure encryption; `bin/dev` handles `db:prepare` correctly. |
| 9 | +- With Docker: `docker compose up --build`. |
| 10 | +- `just start` / `just bash` / `just overmind` / `just teardown` are Docker convenience wrappers (see `justfile`). |
| 11 | + |
| 12 | +### Tests |
| 13 | +- Full suite: `bin/rails test` and `bin/rails test:system` (system tests need a real browser and do not run inside Docker; they run automatically on PRs). |
| 14 | +- Single file: `bin/rails test test/models/message_test.rb` |
| 15 | +- Single test by line: `bin/rails test test/models/message_test.rb:42` |
| 16 | +- Docker equivalent: `docker compose run base rails test` |
| 17 | +- Load sample data (users/conversations/etc.) into dev DB: `bin/rails db:fixtures:load` |
| 18 | + |
| 19 | +### Models & assistants seed data |
| 20 | +- `models.yml` and `assistants.yml` declaratively define the language models / assistants provisioned for each user on registration (`User::Registerable`). |
| 21 | +- Re-import after editing `models.yml`: `rails models:import` (also runs automatically on `db:prepare` / server restart). |
| 22 | +- Export current models: `rails models:export[tmp/models.json]`. |
| 23 | +- Similar `rails assistants:import` / `assistants:export[path]` tasks exist for `assistants.yml`. |
| 24 | + |
| 25 | +### Linting |
| 26 | +- RuboCop is configured (`.rubocop.yml`, target Ruby 3.3, Rails cops enabled). |
| 27 | + |
| 28 | +## Architecture |
| 29 | + |
| 30 | +### AI provider abstraction |
| 31 | +- `AIBackend::<Provider>` classes in `app/services/ai_backend/` (`open_ai.rb`, `anthropic.rb`, `gemini.rb`) implement a shared interface for talking to each provider — the base module holds `tools.rb` (function-calling helpers) and `utilities.rb` (shared logic). |
| 32 | +- `APIService#driver` (enum: `openai`/`anthropic`/`gemini`; Groq rides the `openai` driver against a different base URL) selects the backend class via `APIService#ai_backend`. |
| 33 | +- `APIService#effective_token` falls back to a shared default key (`Setting.default_*_key`) when `Feature.default_llm_keys?` is enabled and the user hasn't set their own token. |
| 34 | +- Adding a new provider: add an `APIService.driver` enum value + base URL constant, implement `AIBackend::<NewProvider>` mirroring an existing backend's method names (`set_client_config`, `client_method_name`, `test_execute`), add a deterministic test client stub under `test/support/test_client/`, and add default models to `models.yml`. |
| 35 | + |
| 36 | +### Message generation flow |
| 37 | +- `MessagesController` creates the user's message, then enqueues `GetNextAIMessageJob` to fetch the assistant's reply asynchronously (SolidQueue, optionally run in-process via `RUN_SOLID_QUEUE_IN_PUMA=true`). |
| 38 | +- The job calls `ai_backend.new(...).stream_next_conversation_message`, accumulating chunks onto `message.content_text` and broadcasting partial updates over ActionCable (Turbo Streams) roughly every 100ms; the message is only persisted once the stream finishes. |
| 39 | +- Generation can be cancelled mid-stream (`User#last_cancelled_message_id`); the job checks this and raises `ResponseCancelled` to stop cleanly. |
| 40 | +- Tool/function calls come back as `content_tool_calls` on the message (not streamed incrementally). `GetNextAIMessageJob#call_tools_before_wrapping_up` executes each tool call via `AIBackend#get_tool_messages_by_calling`, persists a message per tool result, and enqueues a follow-up `GetNextAIMessageJob` so the assistant can respond to the tool output (tools implemented in `app/services/toolbox/`, e.g. `gmail.rb`, `google_tasks.rb`, `image.rb`, `memory.rb`, `open_weather.rb`). |
| 41 | +- Provider errors are translated into user-facing chat messages (invalid key, billing/quota, blank response, connection failure) rather than raised to the user; unexpected errors retry up to 3 attempts with backoff before giving up. |
| 42 | + |
| 43 | +### Message versioning / branching |
| 44 | +- Conversations support editing a message and regenerating replies without losing history. Each `Message` has an `index` (position in the conversation) and a `version`; editing/regenerating creates a new `version` starting at that index, and `branched` + `branched_from_version` track where a version forked from another. See `app/models/message/version.rb` and `conversations.yml` fixtures (`:versioned` example) for the exact scheme. |
| 45 | +- Use `conversation.messages.for_conversation_version(version_or_:latest)` rather than querying `messages` directly — it resolves the correct set of messages across branches/versions via a real SQL join, not just an association scope. |
| 46 | + |
| 47 | +### Identity model: Person / User / Client / Credential |
| 48 | +- `Person` is a `delegated_type` (`personable: User | Tombstone`) — the stable identity record; `User` is the "real" account data, `Tombstone` represents a deleted-but-referenced person (e.g. left behind so old messages still resolve an author). |
| 49 | +- `Client` (`belongs_to :person`) represents one login session/device (`platform`: ios/android/web/api) and has at most one active `Authentication` at a time (`authenticate_with!` / `logout!` soft-delete the old one). |
| 50 | +- `Credential` is an STI-ish per-auth-method row (`password_credential`, `google_credential`, `gmail_credential`, `google_tasks_credential`, `microsoft_graph_credential`, `http_header_credential`, `google_app.rb`) belonging to `User`, each holding encrypted OAuth/external identifiers. |
| 51 | +- Multiple auth methods (password, Google OAuth, Microsoft Graph OAuth, HTTP header) are gated by feature flags; enabling HTTP header auth automatically disables password and Google auth (see `Feature.features`). |
| 52 | + |
| 53 | +### Feature flags & settings |
| 54 | +- All flags/settings are declared in `config/options.yml` and read through `Feature.<name>?` / `Setting.<name>` (both use `method_missing`; an undeclared name raises/aborts loudly rather than silently returning nil — check `options.yml` before adding a new flag name). |
| 55 | +- `Feature.enabled?` also checks `Current.user.preferences[:feature][name]` first, so features can be overridden per-user (e.g. dark mode, voice) on top of the global default from `options.yml`. |
| 56 | + |
| 57 | +### Soft deletion |
| 58 | +- Many models (`APIService`, `LanguageModel`, `Assistant`, `Authentication`, etc.) use a `deleted_at` timestamp + `not_deleted` scope instead of hard deletes, to preserve historical integrity (e.g. old messages still reference the language model that generated them). Associations typically come in pairs: `has_many :things` (scoped `not_deleted`) and `has_many :things_including_deleted`. |
| 59 | + |
| 60 | +### Frontend |
| 61 | +- Hotwire (Turbo + Stimulus) + Tailwind; no SPA framework. Streaming UI updates arrive via Turbo Stream broadcasts over ActionCable (PostgreSQL adapter), rendered from `app/views/messages/_message.html.erb` partials re-rendered server-side per chunk. |
| 62 | + |
| 63 | +### Storage |
| 64 | +- Active Storage for uploaded/generated images, backed by Postgres by default or Cloudflare R2 when `Feature.cloudflare_storage?` is enabled. |
| 65 | + |
| 66 | +## Conventions (from repo-wide instructions) |
| 67 | +- Prefer service objects/POROs in `app/services` for provider logic; keep controllers lean. |
| 68 | +- Prefer early returns over nested conditionals; keep service methods short (~25 lines where practical). |
| 69 | +- Don't hard-code model lists in code — use `models.yml` + the import task. |
| 70 | +- Don't introduce provider-specific conditionals in controllers — encapsulate per-provider behavior in the `AIBackend` layer. |
| 71 | +- For provider tests, stub the network client (see `test/support/test_client/`) rather than recording HTTP cassettes. |
| 72 | +- When changing streaming logic, add a test asserting partial-chunk emission order as well as final persistence. |
| 73 | +- API tokens are stored via `encrypts :token`; never log raw tokens (length-only logging is fine). HTTP header auth treats the configured headers as trusted — don't reuse them for untrusted input. |
0 commit comments