qqq 0.2.0

Quick Query Quorum: an in-memory multi-user chat with an AI facilitator
# QQQ

QQQ is a single-process Rust server for a small, facilitated conversation. It serves its browser UI, keeps sessions and credentials in memory, and uses an OpenAI-compatible provider for chat, optional transcription, and optional speech synthesis. Restarting the process discards all state.

## Install and run

Install the release from crates.io, configure it, then run:

```sh
cargo install qqq
cp .env.example .env
qqq
```

To build and run a local checkout instead:

```sh
git clone https://github.com/maxylev/QQQ.git
cd QQQ
cp .env.example .env
cargo run --release
```

The embedded UI is at `http://127.0.0.1:8787` by default.

## Docker and HTTPS

Build and run the repository image with the same `.env` configuration:

```sh
docker build -t qqq:dev .
docker run --rm --env-file .env -e QQQ_BIND=0.0.0.0:8787 -p 8787:8787 qqq:dev
```

Published images are available from GHCR (pin a version tag in production):

```sh
docker run --rm --env-file .env -e QQQ_BIND=0.0.0.0:8787 -p 8787:8787 ghcr.io/maxylev/qqq:latest
```

Put a same-origin HTTPS reverse proxy in front of QQQ in production; browser microphone access normally requires a secure context, and QQQ does not terminate TLS itself. For nginx:

```nginx
location / {
    proxy_pass http://127.0.0.1:8787;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}
```

## Architecture and lifecycle

The server is deliberately one Rust source file and has no database, queue service, or accounts. The global session map only locates a session; each session has its own async lock. Provider requests occur outside that lock. Inputs are batched into one serial model loop per session and delivery is FIFO with at most one active delivery.

Creating a session starts private creator onboarding. The provider may continue the private conversation, or explicitly call `start_group_session` after the creator asks for a concrete group purpose. Only then is the session active and joinable. The generated group prompt is retained as the first history message. Every participant has a stable API name (`user_1`, `user_2`, …); display names are optional.

## Configuration

| Variable | Required | Default | Meaning |
|---|---:|---|---|
| `OPENAI_API_KEY` | yes | — | Provider bearer credential |
| `CHAT_MODEL` | yes | — | Chat-completions model |
| `OPENAI_BASE_URL` | no | `https://openrouter.ai/api/v1` | OpenAI-compatible API base URL |
| `QQQ_BIND` | no | `127.0.0.1:8787` | Listener address |
| `STT_MODEL` | no | empty | Transcription model |
| `TTS_MODEL` | no | empty | Speech model; must be paired with `TTS_VOICE` |
| `TTS_VOICE` | no | empty | Speech voice; must be paired with `TTS_MODEL` |
| `TTS_FORMAT` | no | `mp3` | Requested speech response format |
| `OPENAI_HTTP_REFERER` | no | empty | Provider HTTP referer header |
| `OPENAI_APP_TITLE` | no | empty | Provider application title header |
| `MAX_SESSIONS` | no | `100` | In-memory session cap |
| `MAX_PARTICIPANTS_PER_SESSION` | no | `8` | Participant cap |
| `SESSION_TTL_SECONDS` | no | `14400` | Idle, socket-free retention period |
| `MAX_HISTORY_MESSAGES` | no | `512` | Provider-history cap |
| `MAX_AUDIO_BYTES` | no | `10000000` | Multipart audio-file cap |
| `INPUT_BATCH_MS` | no | `350` | Input debounce interval |
| `DELIVERY_TIMEOUT_SECONDS` | no | `45` | Delivery acknowledgement deadline |
| `DEFAULT_LANGUAGE` | no | `en` | Default browser/provider language |
| `PRIVATE_CONVERSATION_PROMPT` | no | built in | Creator onboarding instructions |
| `GROUP_SESSION_PROTOCOL_PROMPT` | no | built in | Appended group delivery protocol |

Required strings are trimmed and non-empty. Numeric limits must be nonzero. No provider setting is logged.

## Audio modes

Browser recording is independent of configured speech services.

| `STT_MODEL` | TTS pair | Browser audio input | Delivery output |
|---|---|---|---|
| unset | unset | typed `input_audio` sent to `CHAT_MODEL` | text |
| set | unset | `audio/transcriptions` | text |
| unset | set | typed `input_audio` sent to `CHAT_MODEL` | audio plus text |
| set | set | `audio/transcriptions` | audio plus text |

Direct chat audio includes raw-byte base64 and a required audio format, with a text content part preserving the participant identity. Multipart input accepts only the `audio` field and supported browser audio MIME types. The server authenticates and validates session/onboarding state before reading multipart bytes or contacting STT.

## Authentication and HTTP API

Participant tokens are bearer credentials. They are returned only from creation/join and are never included in public session views. Except creation, every session HTTP route requires `Authorization: Bearer <participant_token>`.

| Method and path | Request | Success |
|---|---|---|
| `POST /api/sessions` | JSON `{"browser_language":"en"}` | `{"session_id","participant_token","api_name"}` |
| `POST /api/sessions/{id}/join` | JSON language object | same creation shape |
| `GET /api/sessions/{id}` | — | public session state |
| `POST /api/sessions/{id}/bootstrap/answer` | JSON `{"text":"…"}` or multipart audio | `{"ok":true,"session_created":bool}` |
| `GET /api/sessions/{id}/onboarding/audio` | — | creator-only audio bytes |
| `POST /api/sessions/{id}/input/text` | JSON `{"text":"…"}` | `{"ok":true}` |
| `POST /api/sessions/{id}/input/audio` | multipart `audio` | `{"ok":true}` |
| `POST /api/sessions/{id}/interrupt` | — | `{"ok":true}` |
| `POST /api/sessions/{id}/deliveries/{did}/complete` | — | `{"ok":true}` |
| `GET /api/sessions/{id}/deliveries/{did}/audio` | — | authorized active-delivery audio bytes |
| `POST /api/sessions/{id}/leave` | — | `{"ok":true}` |

JSON input is content-type checked, bounded, strongly typed, and rejects malformed bodies and unknown fields. Text is trimmed and limited to 4,000 characters for both onboarding and active input. Standard API failures use exactly:

```json
{"error":{"code":"invalid_request","message":"The request could not be completed."}}
```

Status and code distinguish authorization, missing session, conflicts, provider failures, history limits, and audio-size failures. A stale or unauthorized delivery acknowledgement returns `409` with `delivery_conflict` and cannot alter a later delivery.

## WebSocket and delivery sequencing

Connect to `GET /api/sessions/{id}/ws`, then send `{"type":"hello","token":"…"}` within ten seconds. Events are JSON with a `type` field:

* `session.ready`
* `onboarding.prompt`: `delivery_id`, `text`, `audio`
* `participant.joined`, `participant.left`, `participant.named`: `api_name`
* `ai.thinking`
* `delivery.preparing`: `target` (`user_N` or `all`)
* `delivery.started`: `delivery_id`, `text`, `audio`
* `delivery.completed` and `delivery.interrupted`: `delivery_id`
* `input.accepted`: `api_name`
* `floor.changed`: `floor_holder` (or `null`)
* `error`: `code`

Targeted deliveries are FIFO. A `reply_to: "all"` tool dispatch creates one delivery ID, synthesizes speech once, and sends the same started event to every currently eligible recipient. Each recipient is authorized to retrieve its audio and must acknowledge the shared ID. It completes after every pending recipient acknowledges, disconnects, or the timeout expires; `next_speaker` is applied once at completion. Public state exposes its target as `all`.

New accepted input interrupts an active delivery, clears obsolete queued deliveries, emits `delivery.interrupted`, changes the floor to the input participant, emits `floor.changed`, then emits `input.accepted`. Clients must abort playback/text waiting on interruption and must not acknowledge that delivery. Socket queues are bounded; saturation closes/removes the saturated socket rather than silently retaining a healthy-looking connection. Event serialization failures close the connection rather than emitting replacement data.

An authenticated active participant may also call `/interrupt` before recording. It interrupts active/queued delivery and changes the floor without submitting a model message; its events are `delivery.interrupted` (when applicable) followed by `floor.changed`.

## Limits and operations

Multipart audio accepts exactly one `audio` field and is bounded both by `MAX_AUDIO_BYTES` and a small framing allowance; JSON has a fixed 16 KiB transport bound; provider dispatch text has a 2,000-character cap. Session memory grows only within configured session, participant, history, and audio limits. This is an in-memory service: it offers no persistence, horizontal coordination, account management, delivery recovery after restart, or TLS termination.

## Checks and live tests

Offline checks:

```sh
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo package --locked
docker build -t qqq:dev .
```

Ignored live tests use configured provider credentials and may spend credits:

```sh
set -a; source .env.test; set +a
cargo test live_ -- --ignored --test-threads=1
```

The live suite exercises real chat promotion/group delivery and conditionally skips optional STT/TTS round-trip checks when their independent settings are absent.

## License

MIT. See `LICENSE`.