Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
# Local development
DATABASE_URL="file:./data/dev.db"

# Required when DATABASE_URL uses libsql:// or https://
TURSO_AUTH_TOKEN=""

# Optional Playwright development server port
NEXT_BUN_SYSTEM_TEST_PORT=3001
# The web app never loads a model. It talks to an mlx-vlm server started
# separately. Set this only to reach one on another host or port.
VLM_CHAT_INFERENCE_URL="http://127.0.0.1:8080"
7 changes: 6 additions & 1 deletion .github/workflows/run-react-doctor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,20 @@ jobs:
- name: Setup environment
uses: ./.github/actions/setup

# Pinned rather than installed: the tool pulls 160-odd packages, and this
# advisory job is the only thing that runs it. A floating @latest would
# also change what CI reports without a commit saying so.
- name: Run React Doctor
shell: bash --noprofile --norc -eo pipefail {0}
env:
NO_COLOR: '1'
REACT_DOCTOR_VERSION: 0.9.11
run: |
{
echo "# React Doctor"
echo
echo '```text'
} >> "$GITHUB_STEP_SUMMARY"
bunx --bun react-doctor@latest . --project next-bun --verbose --offline --blocking none | tee -a "$GITHUB_STEP_SUMMARY"
bunx --bun "react-doctor@${REACT_DOCTOR_VERSION}" . --no-score \
| tee -a "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
3 changes: 0 additions & 3 deletions .github/workflows/run-system-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,5 @@ jobs:
- name: Install Playwright browsers
run: bunx --bun playwright install --with-deps chromium firefox webkit

- name: Prepare local database
run: bun run db:setup && bun run db:seed

- name: Run system tests
run: bun run test:system
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ next-env.d.ts

# prisma
/src/generated/prisma

# python
/inference/.venv
__pycache__/
*.py[cod]
48 changes: 48 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,51 @@

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

# vlm-chat

The inference server is a separate process running stock `mlx_vlm.server`; no
Python source belongs in this repository. `src/lib/inference/` is the only code
that may call it, and `inference/justfile` is where that server is operated
from — do not add npm scripts that wrap it.

## Constraints of that server

- `image_url.url` resolves http(s) URLs and local filesystem paths. The upload
schema therefore accepts `{ mimeType, dataBase64 }` and never a URL. Do not
add a "paste an image URL" field: it would make the machine running the model
an SSRF and arbitrary-file-read target.
- Images are collected from every user message, flattened, and counted into
`num_images` for the chat template. Send image parts for the newest user turn
only; `src/features/completion/messages.ts` owns that rule.
- `seed` defaults to 0, so a request without one is deterministic. One is drawn
per request and stored on the turn.
- One model stays loaded; naming a different one unloads the previous one.
- Failures after generation starts arrive as `data: {"error": ...}` inside a 200
response, so payloads must be inspected rather than status codes alone.
- The default bind address is `0.0.0.0` with open CORS. Start it on
`127.0.0.1`.

## Repository constraints

- `src/app/api/**/route.ts` must not import from `next/*` at runtime, only as
types. The integration tests call these handlers directly.
- `await connection()` at the top of every page is load-bearing: without it the
route is prerendered at build time against an unmigrated database.
- The CSP allows `img-src 'self' data:`. Image previews use data URLs and stored
images are served from `/api/attachments/<id>`; `blob:` will not render.
- Tests must never require a live inference server. Both stubs live in
`tests/integration/fixtures/` and `tests/system/fixtures/`.
- A conversation holds at most one running completion, claimed in
`src/lib/running-completions.ts` before the user turn is written. Building a
prompt while another turn is streaming into the same conversation would omit
that turn's reply, so the second request is refused rather than queued.
- Cancellation is keyed by an id the client chooses, not by the assistant
message's, so a turn can be stopped before any row exists to name.
- `prisma/migrations` is now append-only. Its history is the applied baseline;
the earlier rewrite was safe only because nothing had ever run it.
- The dev server runs `--webpack`, in `package.json` and in
`playwright.config.ts` alike. Turbopack's dev server leaves `@prisma/client`,
`@libsql/client` and `libsql` external and then requires version-suffixed
names that do not exist, so every database-backed page answers 500 on a cold
`.next`. `next build` is unaffected and stays on Turbopack.
147 changes: 118 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,57 +1,142 @@
# next-bun
# vlm-chat

A chat client for a vision-language model running on your own machine. Text and
images go to a local MLX inference server; replies stream back and the whole
conversation is kept in SQLite.

The point of the repository is the boundary. The model runs in its own process,
started by hand, and the web app reaches it only over an OpenAI-compatible HTTP
API. Nothing in `src/` loads a model, and nothing in `inference/` knows the app
exists.

```text
inference/ a uv project that pins mlx-vlm and holds no source code
↑ OpenAI-compatible HTTP on 127.0.0.1:8080
src/lib/inference/ the only code that talks to that port
src/app/api/chat relays the stream and records the turn
↑ same-origin server-sent events
src/app/_components the browser client
```

`next-bun` is a minimal full-stack Next.js starter for Bun. It includes a
database-backed Hello World page, a local SQLite workflow, and a Turso runtime
configuration through the same Prisma boundary.
The browser never contacts port 8080. The Content-Security-Policy in
`next.config.ts` has no `connect-src`, so it inherits `default-src 'self'` and a
direct call would be blocked. The Next server is the only client of the model.

## Requirements

- Apple silicon, which is what MLX runs on
- Bun 1.3.14
- uv for the inference project, plus just and jq to drive it

## Setup

The setup command creates `.env`, installs dependencies and Playwright browsers,
applies local migrations, and seeds the greeting.

```bash
bun run setup
bun run dev
cd inference && just sync
```

The application is available at http://localhost:3000.
`bun run setup` installs dependencies and Playwright browsers. The inference
project resolves its own Python and pins mlx-vlm to one release.

## Database

Local development uses SQLite by default.
Models are whatever sits in the local HuggingFace cache:

```dotenv
DATABASE_URL="file:./data/dev.db"
TURSO_AUTH_TOKEN=""
```bash
cd inference && just download mlx-community/Qwen3-VL-4B-Instruct-4bit
```

The local database commands are:
## Run

Two processes, started separately.

```bash
bun run db:setup
bun run db:migrate -- --name migration-name
bun run db:seed
bun run db:reset
bun run db:studio
cd inference && just serve
bun run dev
```

Turso uses the same Prisma client with a remote URL and token.
The application is available at http://localhost:3000. `bun run dev` applies any
pending migrations first, so a fresh clone needs nothing else.

The development server runs webpack rather than Turbopack. Turbopack leaves
`@prisma/client`, `@libsql/client` and `libsql` unbundled — they are in Next's
own opt-out list — and then emits requires for version-suffixed names such as
`@prisma/client-2c3a283f134fdcb6/runtime/client`, which exist nowhere on disk.
Every page that reaches the database answers 500 on a cold `.next`, and it never
recovers. `next build` is unaffected, so production keeps using Turbopack.

`just` lists everything the inference side can do — `health` and `models` report
what the server sees, `unload` releases the loaded model, and `ask` checks
generation without going through the app.

The server binds `127.0.0.1` deliberately: mlx-vlm defaults to `0.0.0.0` with
open CORS, which would publish your local models to the whole network. It is
also started without `--model`, so it loads whichever model a request names. The
first request for a model can take tens of seconds while its weights are read
from disk.

## Configuration

There is one setting, and it has a working default:

```dotenv
DATABASE_URL="libsql://database-name.turso.io"
TURSO_AUTH_TOKEN="token"
VLM_CHAT_INFERENCE_URL="http://127.0.0.1:8080"
```

Pending migrations, seed data, and connectivity are managed explicitly.
Set it in `.env` only to reach a server on another host or port. The database is
local SQLite at `data/dev.db`; that path is not configurable, because supporting
one database is the whole point of the choice. `DATABASE_URL` overrides it, and
exists so the tests can run against their own file: a temporary one for the
integration suite, `data/system-test.db` for the browser suite.

## Behaviour worth knowing

The model list is read from the server's `/v1/models`, which reports every MLX
model in the local HuggingFace cache. The app holds no list of its own, so a
text-only model can be selected; sending it an image fails, and the server's
own message is shown.

Only one model stays loaded. Choosing a different one unloads the previous one,
so switching mid-conversation costs a reload.

Images are sent for the newest turn only. The server collects images from every
user message, flattens them into one list, and hands the count to the chat
template, which then places that many image tokens by its own rule — so
replaying older images misplaces them. Earlier turns are sent as text with a
sentence stating that images were attached. Nothing is deleted: the images stay
in the database and stay visible in the transcript.

Thinking is decided by which model you pick, not by a setting. A thinking
template opens its own reasoning block, which is what makes the server return
`reasoning_content` separately from `content`; the app renders that in a
collapsible panel and never parses `<think>` tags. The app does not send
`enable_thinking`, because the model list is arbitrary and the field's effect on
an unknown template cannot be predicted. Starting the server with
`--enable-thinking` — `just serve-thinking` — changes its default without
affecting the app.

A seed is drawn per request. Without one the server uses 0, and identical
prompts return identical text even above temperature 0. The seed is stored on
the turn it produced.

A conversation generates one reply at a time. A second request for the same
conversation is refused with 409 rather than queued, because its prompt would
otherwise be built from a history that is missing the reply still arriving. Stop
the running turn, or wait for it. Different conversations are free to overlap,
though the server itself runs a single worker and will serialise them.

Stopping a turn works from the moment it is sent, including the long wait while
a model is read off disk. The browser names the turn with an id it chose itself,
so there is nothing to wait for before it can say stop.

## Database

Local SQLite through Prisma. `bun run dev` applies migrations already; these are
for changing the schema and for looking at what is stored.

```bash
bun run turso:migrate
bun run db:seed
bun run turso:health
bun run db:migrate -- --name migration-name
bun run db:reset
bun run db:studio
```

## Quality
Expand All @@ -66,9 +151,13 @@ bun run build

Unit tests live beside their owning modules. Cross-boundary tests live under
`tests/integration`, and browser-level contracts live under `tests/system`.
Neither tier needs a running model: both drive a stub that speaks the same HTTP
contract, which is also why the suite passes on Linux CI where MLX cannot run.

## Structure

- `src/app` owns route and rendering boundaries.
- `src/app` owns route and rendering boundaries, including the API routes.
- `src/features` owns application behavior and persistence orchestration.
- `src/lib` owns environment and infrastructure connections.
- `inference` pins the inference server and contains no source code; its
`justfile` is where that side is operated from.
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"!node_modules",
"!bun.lock",
"!playwright-report",
"!test-results"
"!test-results",
"!inference"
]
},
"formatter": {
Expand Down
4 changes: 2 additions & 2 deletions doctor.config.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"ignore": {
"rules": [],
"files": ["playwright-report/**", "scripts/**"],
"files": ["playwright-report/**", "scripts/**", "inference/**", "tests/**"],
"overrides": []
},
"lint": true,
"deadCode": true,
"verbose": false,
"verbose": true,
"blocking": "none",
"customRulesOnly": false,
"share": false,
Expand Down
1 change: 1 addition & 0 deletions inference/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
61 changes: 61 additions & 0 deletions inference/justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Operations for the local inference server. Run from this directory.
#
# Nothing here knows about the web app: the boundary between them is the
# OpenAI-compatible HTTP API this server exposes.

host := "127.0.0.1"
port := "8080"

endpoint := "http://" + host + ":" + port

_default:
@just --list

# Install the pinned mlx-vlm environment.
sync:
uv sync

# The host is pinned on purpose: mlx-vlm defaults to 0.0.0.0 with open CORS,
# which would publish every downloaded model to the whole network. No --model
# is passed either, so the server loads whichever one a request names, and the
# first request for a model waits while its weights are read from disk.

# Serve every model in the local HuggingFace cache.
serve:
uv run mlx_vlm.server --host {{host}} --port {{port}}

# The web app never sends `enable_thinking`, so this only changes what a model
# whose template does not open a reasoning block on its own will do. The
# Qwen3-VL Thinking models do not need it.

# Serve with thinking enabled by default.
serve-thinking:
uv run mlx_vlm.server --host {{host}} --port {{port}} --enable-thinking

# Report whether the server is up and which model it holds.
health:
@curl -sf {{endpoint}}/health | jq '{status, loaded_model}'

# List the models that can be served, which is whatever is in the cache.
models:
@curl -sf {{endpoint}}/v1/models | jq -r '.data[].id'

# Release the loaded model without stopping the server.
unload:
@curl -sf -X POST {{endpoint}}/unload | jq -r '.message // .status'

# The body is built by jq rather than by string interpolation: a prompt is
# free text, and an apostrophe or a quote in it would otherwise end the shell
# word or the JSON string.

# Ask one question, to check generation end to end.
ask model prompt="Reply with exactly: pong":
@jq -nc --arg model {{quote(model)}} --arg prompt {{quote(prompt)}} \
'{model: $model, messages: [{role: "user", content: $prompt}], max_tokens: 128}' \
| curl -sf {{endpoint}}/v1/chat/completions \
-H 'Content-Type: application/json' --data-binary @- \
| jq -r '.choices[0].message | (.reasoning_content // empty), (.content // empty)'

# Download a model, after which it appears in `models`.
download model:
uv run hf download {{quote(model)}}
11 changes: 11 additions & 0 deletions inference/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[project]
name = "vlm-chat-inference"
version = "0.1.0"
description = "Pinned mlx-vlm OpenAI-compatible server for vlm-chat. No source code lives here."
requires-python = ">=3.12,<3.13"
# Pinned exactly: the SSE framing, the reasoning split and the image flattening
# the web app is written against are behaviours of this release.
dependencies = ["mlx-vlm==0.6.12"]

[tool.uv]
package = false
Loading