# AI House — Hybrid AI Chat for WordPress

An AI chatbot for WordPress built around a **hybrid architecture**: quick questions go
straight to an LLM, deeper questions are answered from your own content through RAG.
Designed from the ground up to survive **shared hosting** — every outbound call has a
hard timeout, a circuit breaker, an answer cache, and a quota guard.

- **Version:** 1.22.0
- **Requires:** WordPress 6.2+, PHP 7.4+
- **License:** GPL-2.0-or-later
- **Text domain:** `aihouse` — the WordPress.org slug. The directory requires the text domain to match the slug, and a mismatch is a hard error in the automated scan.

---

## Install from GitHub

1. Download the ZIP from the **Releases** page of this repository (not the "Code" button — that archives the
   source, which works too but has no version in the name).
2. WordPress admin → **Plugins → Add New → Upload Plugin** → choose the ZIP → **Install Now** → **Activate**.
3. Open **AI House** in the admin menu and follow **Get started** — it lists the four things a fresh install needs
   and says which of them are already done.

Requires WordPress 6.2+ and PHP 7.4+. No build step is needed to install: the admin bundle is committed, because a
plugin a shop owner downloads from GitHub must not need `npm install` first.

## Why this one

Most AI chatbot plugins assume a VPS. On shared hosting, the scarce resource is not CPU
or MySQL — it is **PHP workers**, and there are only 5–15 of them for the whole account.
A chatbot that holds a worker for five seconds per message will take your entire
website down, including page views from visitors who never opened the chat.

AI House is built around that constraint:

| Problem on shared hosting | How AI House handles it |
|---|---|
| `max_execution_time` kills the request mid-LLM call | Hard timeout capped at 20s, default 8s; background jobs get their own budget |
| A dead AI provider holds workers hostage | Circuit breaker opens after N failures and returns a fallback reply immediately |
| `wp_options` bloats from transients | No transients — rate limiting uses a dedicated indexed table, one query per message |
| Every message writes to MySQL | Logs are queued and batch-inserted by cron, outside the request |
| WP-Cron only fires on page loads | The queue self-schedules when new work arrives, and a real cron job is documented |
| Free AI tiers run out silently | Proactive quota check returns a friendly message instead of a raw 429 |

**Measured cost per chat request: 3 database queries, 1–3 outbound HTTP calls, and 0
writes to `wp_options`.**

---

## Features

### Two answer branches
- **Quick chat (2a)** — direct LLM call, low latency.
- **Deep knowledge (2b)** — retrieve relevant chunks from a vector DB, then answer with
  them as context. Citations are shown to the visitor.
- **Automatic mode** decides per question, so greetings never pay for a vector search.

### Providers
- **LLM:** OpenAI, Google Gemini, **OpenRouter** (including free models)
- **Model lists:** every provider offers a picker — OpenRouter splits free and paid, OpenAI and Gemini
  serve one list each
- **Vector store:** Supabase (pgvector), Pinecone, Qdrant
- Per-provider API keys, so switching providers never loses your keys
- **Paste a key and the model list appears** — the key is verified against the provider and stored on
  the spot, so nothing has to be saved before a model can be picked
- API keys encrypted at rest with AES-256-GCM, or supplied as `wp-config.php` constants

### Knowledge sources
- Any public post type you tick (posts, pages, products, custom types)
- **File import** — Markdown, CSV, JSON, plain text, with preview before import
- A dedicated Knowledge post type, editable in wp-admin
- Automatic re-index when content changes

### Multilingual
- **The chat widget ships with Vietnamese and English enabled by default**, and 12
  languages are built in: `vi`, `en`, `zh`, `ja`, `ko`, `th`, `id`, `fr`, `de`, `es`, `pt`, `ru`.
- Visitors switch language with a button in the widget; the bot answers in the chosen
  language, with per-language welcome text, placeholder, system prompt, and language
  instruction.
- **The admin interface is fully translatable and ships with an English source plus a
  complete Vietnamese translation.** Any other locale falls back to English.
- Content is configured per language, with automatic fallback to the default language.

### Admin dashboard (React)
- **Overview** — messages, sessions, cache hit rate, latency, queue, circuit breaker, RAG progress
- **Conversations** — session list and full message threads with language and mode
- **Knowledge** — file import, item list, index progress, queue, failed jobs
- **Diagnostics** — connection tests, environment, running configuration, REST paths

The React bundle is pre-built and committed, so it works immediately after upload. If the
bundle is missing, the plugin falls back to a plain PHP settings page — it never white-screens.

### Also included
- Floating widget and `[mybot]` shortcode, with light/dark themes and per-language output
- **Shortcut on the WordPress admin toolbar**, on the left after the site name, with quick
  links to the dashboard, knowledge list and settings
- Answer cache with normalisation, keyed per language
- Visitor feedback (helpful / not helpful) stored per message
- Answer `sources` returned to the widget so visitors can verify claims
- Queue with automatic retry, backoff, and a time budget per run
- Log retention with automatic daily purge

---

## Installation

1. Upload the `mybot` folder to `/wp-content/plugins/`, or install the ZIP through
   **Plugins → Add New → Upload Plugin**.
2. Activate the plugin. Four tables are created automatically.
3. Open **AI House** in the admin menu.

### Quick start (free, no vector DB)

1. **AI House → LLM (2a)** → Provider: `OpenRouter`, paste your key from
   [openrouter.ai/keys](https://openrouter.ai/keys).
2. The key is checked as soon as you paste it, and stored when OpenRouter accepts it. The model
   list appears right away — pick one, no need to save first.
3. Leave **Vector store** as `None` on the RAG tab.
4. Save, then click **Test connection** — you should see `LLM connection succeeded`.
5. Visit your site and open the chat widget.

### Adding your own knowledge (RAG)

1. Create a Supabase project, choosing the region closest to your hosting.
2. Run [`sql/supabase-schema.sql`](../../../sql/supabase-schema.sql) in the SQL Editor.
3. **AI House → RAG (2b)** → Vector store: `Supabase (pgvector)`, paste the project URL and
   the `service_role` key.
4. **AI House → Knowledge** → drag in a `.md` / `.csv` / `.json` / `.txt` file, click
   **Preview**, then **Import into knowledge**.
5. Click **Index knowledge** and let WP-Cron work through the queue.

Templates are downloadable from the import panel:
`assets/templates/knowledge-template.{md,csv,json}`

Full guides live in the project `docs/` folder:

| Document | Contents |
|---|---|
| `docs/06-setup-supabase-openrouter.md` | Supabase + OpenRouter setup, including a fully free stack |
| `docs/07-nhap-tri-thuc-tu-file.md` | File formats, templates, and how to write content that retrieves well |

---

## REST API

Namespace: `/wp-json/mybot/v1/`

| Method | Route | Access | Purpose |
|---|---|---|---|
| `POST` | `/chat` | Public (nonce) | Ask a question |
| `POST` | `/feedback` | Public (nonce) | Rate the last answer |
| `GET` | `/config` | Public (nonce) | Widget configuration and languages |
| `GET` | `/history` | Admin | Sessions and message threads |
| `POST` | `/index` | Admin | Start indexing |
| `GET` | `/index/status` | Admin | Progress, queue, circuit breaker, system info |
| `POST` | `/index/reset` | Admin | Clear indexing fingerprints |
| `POST` | `/test-connection` | Admin | Test LLM and vector store |
| `POST` | `/test-lookup` | Admin | Run a knowledge base lookup for a question and report what came back |
| `GET` | `/models` | Admin | Model list for the saved provider (`free` is required) |
| `POST` | `/connect` | Admin | Verify a provider + API key from the form, store it on success, and return its model list |
| `GET` | `/knowledge` | Admin | List knowledge items |
| `POST` | `/knowledge/import` | Admin | Import from file content |
| `POST` | `/knowledge/delete` | Admin | Delete one item or all |

---

## Hooks for developers

| Hook | Type | Arguments | Purpose |
|---|---|---|---|
| `mybot_reply` | filter | `$reply, $message, $session_id` | Replace the generated answer |
| `mybot_llm_messages` | filter | `$messages, $session_id, $lang` | Modify the prompt before sending |
| `mybot_should_load` | filter | `$load` | Prevent the widget loading on some pages |
| `mybot_widget_args` | filter | `$args` | Change widget parameters |
| `mybot_rate_limit` | filter | `$limit, $bucket` | Change the rate limit |
| `mybot_history_turns` | filter | `$turns, $session_id` | Conversation memory depth |
| `mybot_queue_handlers` | filter | `$handlers` | Register your own background job types |
| `mybot_after_save_message` | action | `$insert_id, $data` | Run after a message is stored |

---

## Development

```bash
# Rebuild the admin dashboard
npm install
npm run build

# Regenerate translations (.pot, and compiled .mo for every .po)
node tools/build-i18n.mjs
```

`tools/build-i18n.mjs` extracts every translatable string from the PHP **and** JavaScript
sources, writes `languages/mybot.pot`, then for each `languages/mybot-<locale>.po` it
reports untranslated strings, compiles the `.mo`, reads it back to verify it, and checks
that no `sprintf` placeholder was lost in translation. It exists because `msgfmt` is not
available on most shared hosts.

### Translation note

Source strings are **English**, following WordPress convention, so any locale without a
translation file falls back to English automatically.

| File | Contents |
|---|---|
| `languages/mybot.pot` | Template with every translatable string |
| `languages/mybot-vi.po` | Vietnamese translation (323 strings) |
| `languages/mybot-vi.mo` | Compiled Vietnamese translation |

Adding a language means dropping in `languages/mybot-<locale>.po` and re-running the build
script — it compiles and verifies every `.po` it finds.

`tools/migrate-to-english-source.mjs` is a one-time migration script that converted the
original Vietnamese source strings to English. It is kept for reference and is not part of
the normal build.

---

## Frequently asked questions

**Does this train or fine-tune a model?**
No. It uses RAG: your content is split into chunks, converted to vectors, and stored. At
question time the closest chunks are retrieved and placed in the prompt. Nothing is
trained, updates are instant, and you can see which source produced each answer.

**Can I use it without paying for AI?**
Yes. OpenRouter offers free models, and Gemini has a free embedding tier. Note that
OpenRouter free models are limited to **20 requests per minute and 50 per day** until you
have purchased 10 credits — the plugin checks this before calling and returns a friendly
message instead of an error.

**What happens if the AI provider goes down?**
The circuit breaker opens after a configurable number of consecutive failures and serves
your fallback message immediately, without holding a PHP worker. The real reason is
recorded and shown on the dashboard.

**Where are conversations stored?**
In `{prefix}mybot_messages`, written in batches by a background job. Messages are also sent
as context for follow-up questions, and older logs are purged automatically. Since 1.5.8 you can
optionally keep a **second copy in Supabase**, next to the vectors, so the history can be read and
queried from outside WordPress and the write load leaves MySQL: switch on *Also copy conversations to
Supabase* on the RAG tab. WordPress always keeps the full log, and a failed copy can never lose a
message or break the chat.

**Does it work on multisite?**
Yes. Each site gets its own tables, options, and knowledge base.

**Will the knowledge pages show up in Google?**
No. The knowledge post type is `public = false`, so it creates no front pages and is
excluded from search. Citations therefore display the title only. If you want linked
citations, index regular pages or posts instead.

---

## Changelog

The complete release history lives in `CHANGELOG.md` at the repository root and in `readme.txt`
inside the plugin; this list covers the changes worth reading on their own.

### 1.10.0
- **The knowledge lock lost its switch, its admin code and its first-run notice** — removed at the plugin
  author's request. The file is the switch: `wp-content/uploads/mybot/khoa-tri-thuc.md` present means the
  lock is on, `che-do: off` pauses it, deleting the file removes it. The card in Settings now reports that
  state and the path, and nothing else. `MYBOT_Knowledge_Lock` reads the file and has **no writer at all**,
  so a plugin update cannot change what you put in it
- **Why the code went rather than got fixed:** it guarded a button against somebody who has wp-admin but not
  the server's files, and it cost a wide failure — an owner who lost the code could not switch their own lock
  off, and that path raised a PHP warning (see 1.9.10). Anyone who can edit the file always had the real power
- **A new install no longer arrives locked.** Activation used to create the lock file and hand over a
  generated code; a fresh site now starts unlocked. Step 4 of Get started reports the state instead of
  offering a decision, and its red warning tells you to index content rather than to switch a lock off
- **Guard rules changed with it:** `check-knowledge-lock.mjs` is 9 rules and now checks the *absence* of
  controls (no admin-post action, no handler, no code field, no button, no notice option) and the absence of a
  writer in the class; `check-get-started.mjs` gained a rule that step 4 contains no form, input or button.
  Both fail on the released 1.9.10 package and pass here

### 1.9.10
- **Turning the knowledge lock off with a code that does not match raised a PHP warning instead of an
  explanation.** The shared `is_wp_error( $result )` check at the end of the lock handler ran on the one
  branch that never assigned `$result`, so the failure path printed `Undefined variable $result`. It shipped
  with the lock in 1.9.2 and survived eight releases: the success path never reaches that line, and the screen
  still redirected correctly, so only `WP_DEBUG` shows it. A guard rule (R11 of `check-knowledge-lock.mjs`)
  now refuses that shape, proved against the released 1.9.2, 1.9.3 and 1.9.9 packages
- **The "wrong code" notice says the way out:** the path of the lock file, and both ways to open the site
  without a code — set `che-do: off` in that file, or delete it, because the file IS the switch. It also says
  that a code generated and dismissed before it was saved cannot be recovered

### 1.9.9
- **1.9.8 broke every wp-admin screen** — the admin menu read `self::ADDONS_SLUG`, a class constant that was
  never declared, and PHP resolves class constants at run time. Sixteen static checks passed it. It was found
  by running the plugin for real: a WordPress install in a scratch folder, every admin screen requested over
  HTTP, file and line printed in one run
- **Rule R7 of `check-constants.mjs`:** every `self::NAME` / `static::NAME` needs a `const NAME` in the same
  file. It fails on the released 1.9.8 package, which is what makes it a guard rather than a comment

### 1.9.8
- **The add-on framework** — a registry, eight documented extension points, and an `Add-ons` screen that also
  prints the registrations the core refused and why. With no add-on installed every hook returns the value it
  always did, and nothing in the free plugin waits for a paid one
- **456 WordPress coding-standard violations fixed automatically** across 24 files (521 down to 65)

### 1.6.7
- **Test connection tests the embedding key now**, which it never did: the report called `chat()` and the
  vector store and never called `embed()`. The two keys can differ — a separate "Embedding API key" field
  is preferred over the provider key — so a stale value there gives a fully green report beside indexing
  runs that fail with "API key expired." There is a third line, `Embedding:`, with the model and the
  dimensions it returned
- **Jobs that gave up are listed on the Status card with the reason.** The count was on the Overview and
  the reason was returned by the status route, but no screen rendered it: reading it took a line typed into
  the browser console
- **The indexing progress bar no longer accumulates across runs** — a second pass reported "84 of 42" and
  jumped to 100% before doing any work

### 1.6.6
- **Everything a visitor types into the hand-over form now has a screen of its own**, in the AI House
  menu. It used to be a card at the bottom of the settings page, under four tabs of configuration —
  a strange place to go looking for a customer's phone number. The Requests screen shows the
  question, the answer the bot gave before handing over, the name, email, phone, the visitor's own
  note, the language and the conversation it came from, with filters by status, paging, and buttons
  to reply by email, mark answered, close or reopen
- **The plugin has a logo, built from the brand file rather than redrawn**, in the admin menu, at the
  top of every AI House screen, in the dashboard sidebar and on the chat widget. The menu icon is a
  white cut-out on transparency, because WordPress paints it over its dark sidebar at 60% opacity
  and the full-colour version would arrive there as an opaque white square
- **The Status card no longer says "the request is the line below"** — it names the Requests screen
  and links to it. The sentence was correct when 1.6.5 shipped it and stopped being correct when the
  list moved, which makes it a bug, not a documentation update
- **The launcher kept its logo only until the first click**: the widget rewrote that icon by
  assigning text, which deletes an image; it now toggles a class

### 1.6.2
- **The Status card reports the state of the "question for a human" hand-over**: how many questions
  are waiting, when the last one was saved and under which request number — or, if the last visitor
  attempt failed, the reason it failed
- **A failed submission leaves a trace in WordPress now.** The visitor always saw a small red line
  in the chat, while the administrator saw nothing at all: no row, no log entry, nothing on any
  screen. So "I filled the form and nothing appeared in the plugin" had no answer anywhere — the
  silent-failure shape this project keeps meeting. Every attempt is recorded (`record_saved()` /
  `record_failed()` in `MYBOT_Requests`, option `mybot_support_trace`) and printed on the Status
  card: the option switched off (403), a repeat within five minutes (429), or a storage error such
  as a missing table or an invalid email address
- Written without `_n()` on purpose: the string extractor only reads `__()`/`_e()`, so a plural
  form would never reach the `.pot` and would quietly stay English in a Vietnamese admin

### 1.6.1
- **The "table does not match the schema" message names the missing column now.** A real site
  answered `Could not find the 'lang' column of 'mybot_messages' in the schema cache` while the
  plugin said the primary key was probably still the old `id` one — the right family of causes,
  the wrong case, and it sent the reader looking at the key instead of the missing column. The
  hint now quotes the column PostgREST names and gives both routes: check
  `select count(*) from public.mybot_messages;`, then drop and recreate the table when it is 0
  (which is what to expect: nothing has ever been written), or repair it when it is not
- **`sql/supabase-schema.sql` gained a commented repair block** that adds the columns introduced
  after the first version of the schema (`wp_id`, `lang`, `tokens_in`, `tokens_out`) plus the
  unique index on `wp_id`, so a table that already holds rows can be fixed instead of deleted.
  Wait a few seconds afterwards for PostgREST to reload its schema cache
- Tooling: `tools/bump-version.mjs` no longer counts raw occurrences in `package-lock.json`
  (two dependencies happened to sit at 1.6.0 and broke the "exactly 2" rule); it reads the JSON
  and asserts the two fields that actually mean "this package"

### 1.6.0
- **Indexing now proves it can read back what it just wrote.** Every counter until now measured the
  write — "26 of 26, no errors" — and none of them could see that the vector stored under a chunk
  belonged to a different chunk or came from a different embedding model, which is exactly how the
  faults of 1.5.5 and 1.5.7 stayed invisible. After writing a post the indexer asks the store for a
  vector it has just sent and expects it to find that chunk at ~1.00. One check per indexing run,
  never fails the job, and the result appears in the Status card, in the log box after
  **Index knowledge**, and in the indexing status API. A score below 0.99 says the stored vectors
  were made by another model, so lowering Minimum score cannot help and everything must be
  re-indexed; an empty answer says indexing can write but cannot read, which points at the key
- **Test connection now checks the Supabase conversation log too.** When the mirror is on, the
  button writes one probe row (`wp_id = -1`, an ID no message can have) into
  `public.mybot_messages`, removes it again, and explains a refusal per status code: 401 needs the
  `service_role` key, 404 needs the table, 400/409 needs the old table dropped and recreated
- **The log mirror can say what it is doing**: on, switched off after repeated failures, or on but
  never yet successful, plus when it last copied anything. A successful test clears the failure
  counter, so fixing the problem and testing is enough
- **Fixed: mirrored rows carried the wrong WordPress IDs.** MySQL reports the *first* generated ID
  of a multi-row `INSERT` and the code assumed the last, shifting every key down by one per extra
  row in the block. The keys stayed unique, which is why nothing looked broken, but the Supabase
  copy could no longer be joined back to WordPress. When the ID cannot be trusted nothing is
  written, rather than every row sharing one key

### 1.5.9
- **A question the bot cannot answer can now be handed to a human.** When the knowledge base has
  nothing to answer with, or the model says it does not know, the chat asks for a name, an email
  address and an optional phone number and stores the question for a person to answer. Off by
  default. Notification is optional and can go out three ways: a WordPress action for developers, a
  webhook URL for an n8n / Zapier / Make workflow, or an email. A failing webhook is recorded on the
  request and never blocks the visitor. Needs one deactivate/activate so the new table is created
- **Fixed: the Supabase conversation log never worked in 1.5.8.** The key was read with `get()`,
  which returns the *encrypted* value, so every message carried a ciphertext bearer token: a 401 on
  every request and no rows in Supabase while the option was ticked. It now uses `secret()`, and a
  refusal explains itself by status code

### 1.5.8
- **Conversations can be copied to Supabase as well as WordPress.** WordPress keeps the complete log
  and remains the source of truth; this adds a second copy beside the vectors, so the history can be
  read from outside WordPress and the write load leaves MySQL. Off by default, since it sends
  visitor messages to a service outside WordPress
- **The mirror cannot damage anything else.** A failed write never fails the message, the copy is
  idempotent because it keys on the WordPress message ID, and after three failures in a row it
  switches itself off with the reason shown instead of filling the queue with failing jobs

### 1.5.7
- **A chat model is now tried with a real message the moment it is picked.** A reasoning model spends its
  budget thinking and returns empty content, so while it stays saved every visitor question gets the
  fallback message and no citation can appear. It has cost two sessions already, and the model ID never
  gives it away. Picking from the list sends one short message and reports next to the Model field whether
  the model answers or is a reasoning model — before anything is saved
- **Closed a leftover way for vectors to be paired with the wrong chunk**: both embedding providers dropped
  blank entries and re-indexed the batch before sending it, shifting every later chunk by one
- **Indexing refuses a short answer from the embedding provider.** Chunks that came back without a vector
  used to be skipped silently while the post was still recorded as indexed, leaving an invisible hole in
  the knowledge base that no later run would fix

### 1.5.6
- **A red "LLM" line now says what it means for the visitor**: the chat answers every question with the
  fallback message, so no citation can appear until it is fixed — a report spent an evening on the
  knowledge base while the chat itself was down
- **The empty-reply message names the model and the token budget**, instead of leaving the reader to
  compare two log lines to find the model at fault, and mentions that a reasoning model is slower so the
  request timeout matters too
- **"Settings in use" no longer looks like a doubled prefix**: `openai/text-embedding-3-small` through
  OpenRouter used to print as `openrouter/openai/text-embedding-3-small`; it now reads `… via openrouter`

### 1.5.5
- **Fixed embeddings being paired with the wrong chunk.** Both providers matched each vector to a chunk by
  the order the API returned them and ignored the `index` field, which exists for exactly that purpose. A
  response in another order attached every chunk to a different chunk's vector — with no error anywhere:
  indexing still reported 26/26 and every score afterwards was meaningless. Found after a real site
  reported "the closest chunk scored 0.06" on a healthy-looking index. **Re-index after updating**
- **An empty lookup now distinguishes the causes, which need opposite fixes**: the content does not answer
  the question, or the stored vectors do not belong to the embedding model selected now. The second cannot
  be fixed by any threshold — cosine similarity cannot compare two different vector spaces
- **The lookup test proves which one it is** by embedding a chunk that is already stored and searching for
  it: the same model finds it at ~1.0. When it cannot, the report names the Embedding model and a re-index
  instead of Minimum score
- The report prints the settings that produced it and the three closest chunks with their scores

### 1.5.4
- **A knowledge base lookup can be tested on its own**, and the last one is remembered. "The answer
  has no sources" was impossible to act on: the question may never have reached the search, the
  search may have found nothing, or the call may have failed — indistinguishable from outside, and
  each needs a different fix. The RAG tab takes a question and reports what came back, and the
  Status card repeats what the last visitor question did
- **An empty lookup is measured rather than guessed.** The search filters below the threshold in
  SQL, so "no vectors at all" and "every vector scored under the bar" return the same empty list
  while needing opposite fixes. The test asks again with the threshold removed and reports the real
  best score
- Test connection prints the Minimum score it used
- The lookup report is coloured by its result (green / grey / red) from a single source of truth, so a
  healthy lookup is never painted like a failure

### 1.5.3
- **Cached answers keep their sources.** A reply served from the cache carried an empty source
  list, so the same question showed citations once and then none at all
- **The similarity threshold is now a field, and its default dropped from 0.72 to 0.35.** At 0.72
  no chunk on a real site cleared the bar: the search ran, matched nothing, and the model answered
  from its own knowledge with no source shown — indistinguishable from a broken knowledge base.
  A stored 0.72 that nobody changed is lowered once
- The empty "Embedding API key" field no longer reads "Not configured" when the embedding
  provider already has a key: it names the key it is reusing

### 1.5.2
- **Test connection now writes, not just reads.** With Supabase, row level security is enabled and
  no policy exists, so the public "anon" key gets an empty list and HTTP 200 from the search
  function while every real write is refused with "new row violates row-level security policy".
  The test wrote nothing, so it passed on a key that could never index anything; it now writes one
  probe row, removes it again, and names the likely cause when the write is refused
- **Test connection prints the settings it used** — provider, chat model, embedding provider and
  model, vector store. The test reads the saved settings rather than the form, so naming them turns
  "The vector store is not enabled." into an explanation
- **Failed indexing jobs report their error in the Status log**, next to the buttons that produced
  them, instead of only in the database

### 1.5.1
- **Fixed Test connection reporting a working key as broken when the chat model reasons before
  it answers.** The test asks for one short word within 16 tokens; a reasoning model spends that
  on its thinking and returns empty content, so a valid key came back as "returned an empty
  reply" — and the chat path returns the same error, which is why the widget showed the fallback
  message. The test now allows 64 tokens, and an empty reply explains itself: reasoning-only
  output, a token limit, or a model ID that does not exist
- **Fixed indexing jobs left in `processing` forever.** A cron run claims its whole batch up
  front, and on reaching its time budget it released only the job in hand, so the remaining jobs
  were never processed and never reset: indexing stopped part-way and the progress bar froze.
  Every job still waiting in the batch is returned to the queue now
- The settings screen warns when you test the connection with unsaved changes, because the test
  reads the saved settings rather than the form on screen

### 1.5.0
- **OpenRouter can now generate embeddings**, so the whole chatbot can run on one provider and
  one key. `openai/text-embedding-3-small` through OpenRouter has the same 1536 dimensions the
  bundled Supabase schema uses, so no SQL change is needed; OpenRouter serves 33 embedding
  models and a few are free
- OpenRouter namespaces its embedding catalogue while OpenAI and Gemini use flat names, so a
  saved flat name is qualified before it is sent instead of being rejected as unknown
- **Fixed the "Embedding API key" description**, which claimed it reused the *chat* key. It
  falls back to the key of the **embedding provider** — with OpenRouter as the chat provider
  that meant no key at all, and RAG silently never ran
- **Fixed "Items per batch" doing nothing** — the value was saved and never read

### 1.4.2
- **The widget opens in English by default** instead of Vietnamese. An install that never
  picked a language is switched once; a language chosen by the site owner is left alone
- **Fixed Vietnamese text rendering in the wrong font.** Typography is now pinned inside the
  widget — every descendant, plus the form controls, which a browser otherwise gives its own
  default font. A theme whose body font is a webfont loaded with only the `latin` subset has no
  Vietnamese glyphs, so the browser filled them from another font and a single sentence came
  out in two typefaces
- A visitor who already chose a language keeps it, because that choice is stored in the browser

### 1.4.1
- **Fixed the API key check accepting keys that do not work.** The key was verified by loading the
  model list, and OpenRouter serves that list publicly — HTTP 200 with no key and with a wrong key
  alike. Any key, including one from another provider, was therefore reported as accepted and stored,
  while every real call failed with 401 and the widget answered with the fallback message
- Each provider is now verified against an endpoint that requires authentication: `/key` for
  OpenRouter, `/models` for OpenAI, `/models?key=` for Gemini. An unproven key is never stored
- OpenRouter's "Missing Authentication header" is now explained: it means the key is not in
  OpenRouter's format, so the message points at `sk-or-v1-` and at the possibility of a key from
  another provider
- Keys are stripped of invisible characters (non-breaking space, zero-width space, BOM) before being
  stored, since those survive `trim()` and break the request header
- Translations now cover 344 strings

### 1.4.0
- **The API key is verified the moment it is pasted, and the model list appears without
  pressing Save first.** The model endpoint read the provider from the saved settings, so a key
  that had only been typed in was invisible to it. Pasting a key now proves it against the
  provider and stores it once the provider accepts it
- **Model lists for OpenAI and Gemini, not just OpenRouter.** Each provider answers with its own
  catalogue, so every provider can be picked from a dropdown instead of typing a model ID
- Each provider caches its list under its own key, so one provider can no longer be served
  another provider's models
- With OpenAI or Gemini selected, the two OpenRouter buttons become a single **Load models**
  button, because those providers publish one list rather than a free and a paid one
- Translations now cover 343 strings

### 1.3.7
- **Fixed Load paid models listing the same models as Load free models.** The route declared its
  `free` parameter with a default of `true`, so a request that omitted it was answered with the
  free list. The two lists are now **disjoint** — free plus paid adds up to every model — and the
  parameter is **required**, so a caller can no longer silently receive the wrong list; a missing
  parameter is a clear `400` instead
- **The model list is no longer truncated at 300 entries.** With 464 models available and the free
  ones sorted first, 164 paid models could never appear in the dropdown
- **Only one dropdown stays open**, and a failed request now reports its error next to the Model
  field instead of only in the log box at the bottom of the page
- Translations now cover 335 strings

### 1.3.6
- **Reorganised the LLM (2a) tab** into Provider → API key → Model, so the key is entered before
  the model that depends on it
- **One API key field instead of three.** It follows the Provider dropdown and shows that
  provider's stored key as a masked placeholder. All three inputs stay in the form and are only
  hidden — a hidden input still submits, and an empty value means "keep the stored key", so
  switching providers back and forth cannot lose a saved key
- **Added Load paid models beside Load free models**, each with its own dropdown. The REST route
  already accepted a `free` parameter and cached the two lists separately, so no server change
  was needed *(this assumption was wrong — see 1.3.7: the default value made the paid button
  return the free list)*
- Translations now cover 334 strings

### 1.3.5
- **Fixed the "Load free models" button appearing to do nothing.** Two problems stacked up:
  the models were written into a `<datalist>`, which is an autocomplete hint that stays
  invisible until the field is focused and typed into (and Safari does not support it at all),
  and the result message went to the log box inside the **Knowledge (RAG)** card at the bottom
  of the page — far from the button on the **LLM (2a)** tab
- Loaded models now fill a real `<select>` that is revealed next to the button once the list
  arrives, and the outcome is reported on the spot instead of only in the log box
- Picking from the dropdown writes into the Model field, which stays free text so an ID can
  always be pasted by hand

### 1.3.4
- **Fixed the LLM provider setting.** Selecting OpenRouter and saving silently reverted to
  OpenAI: the allow-list used by `MYBOT_Settings::save()` was hard-coded to
  `array( 'openai', 'gemini' )`, so `clean_enum()` treated `openrouter` as invalid and stored
  the default instead
- **Fixed the free model list never loading.** `GET /models` returns
  *"This works with OpenRouter only"* for any other provider, so while the provider could never
  be saved as `openrouter`, the button failed every time — the two symptoms had one cause
- Both provider allow-lists are now derived from `MYBOT_LLM_Factory::options()` and
  `MYBOT_LLM_Factory::embedding_options()`, the same lists that render the dropdowns, so a
  provider can no longer be offered in the form and then rejected on save
- The embedding provider dropdown no longer offers OpenRouter. It was offered but rejected on
  save, which looked like a silent revert. OpenRouter does expose an embeddings endpoint, but
  its model IDs are namespaced (`openai/text-embedding-3-small`) while the stored default is
  not, so enabling it needs more than an allow-list entry

### 1.3.3
- Fixed the admin menu: clicking **AI House** now opens the dashboard instead of the knowledge list.
  WordPress builds the link of a top-level menu item from its *first* submenu entry
  (`_wp_menu_output()` in `wp-admin/menu-header.php` reads `$submenu_items[0][2]`), and the
  knowledge post type submenu is registered before the dashboard's, so the parent link pointed at
  the knowledge list. The dashboard is now moved to the front at a late `admin_menu` priority,
  once every entry exists
- The submenu items now read **Dashboard** then **AI House Knowledge**

### 1.3.2
- Fixed a fatal error that made the plugin impossible to delete: `uninstall.php` referenced a
  constant that only the plugin bootstrap defines, and WordPress does not load the bootstrap
  while deleting. On PHP 8 this aborted the deletion before any file was removed
- Every option, transient and post meta name now lives in one shared file,
  `mybot-constants.php`, loaded by both the plugin and `uninstall.php`
- Uninstall also clears the options and transients added since 1.3.0, and can no longer be
  stopped by a database error

### 1.3.1
- Content defaults (bot name, welcome message, input placeholder, system prompt, fallback
  message) are no longer passed through the translation layer. The settings fields always
  show the English default instead of a sentence translated into the administrator's language
- A stored value that still equals one of the old Vietnamese defaults is upgraded to the
  English default once; values written by hand are never overwritten
- Per-language content for visitors is unchanged

### 1.3.0
- Knowledge import from file: Markdown, CSV, JSON, plain text
- Preview before import, merge or replace modes, fingerprint-based duplicate detection
- Dedicated knowledge post type, editable in wp-admin
- Downloadable templates for each format

### 1.2.0
- React admin dashboard: overview, conversations, knowledge, diagnostics
- OpenRouter provider with free model support
- Per-provider API keys
- Live model list loader
- Free quota check before calling the LLM
- Last failure reason shown on the dashboard

### 1.1.0
- Multilingual chatbot: visitors switch language in the widget
- 12 built-in languages with per-language content
- `lang` column in the log table with an automatic schema upgrade
- Cache keys separated per language

### 1.0.0
- First release: hybrid architecture, three LLM providers, three vector stores,
  queue, circuit breaker, rate limiting, answer cache

---

## License

GPL-2.0-or-later. See <https://www.gnu.org/licenses/gpl-2.0.html>.
