Compare commits

...

21 Commits

Author SHA1 Message Date
Ryan Chen bddd741fcc Fix missing React import in AnswerBubble 2026-09-11 16:40:48 -04:00
ryan 2db7e6f2e5 Merge pull request 'Copy button + hover highlight on assistant messages' (#41) from copy-answer-messages into main
Reviewed-on: #41
2026-09-11 16:28:52 -04:00
Ryan Chen b23fd19c4b Copy button + hover highlight on assistant messages 2026-09-11 16:27:51 -04:00
ryan 4e3842495f Merge pull request 'Update CLAUDE.md to reflect current repo state' (#40) from update-claude-md into main
Reviewed-on: #40
2026-09-11 16:15:59 -04:00
Ryan Chen 434c394651 Update CLAUDE.md to reflect current repo state
Adds the email/iMessage/WhatsApp/scheduled-message blueprints,
simbakit subproject, household integration utils (YNAB, Mealie,
Obsidian, S3, Immich), and corrects environment variable names to
match .env.example.
2026-09-11 16:14:19 -04:00
Ryan Chen 20d44bb3b3 Autofocus textarea on new conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-10 14:14:59 -04:00
Ryan Chen d8d01a5fff Zero-downtime blue-green deploys behind Caddy health checks
Split the raggr service into raggr-blue (8080) and raggr-green (8081)
behind compose profiles, add a /api/health endpoint and container
healthcheck, and add scripts/deploy.sh to build the inactive color,
wait for it to become healthy, then gracefully stop the old one.
Makefile targets resolve the active color automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 01:41:37 -04:00
Ryan Chen 3318839551 Sort conversations by last activity
Bump Conversation.updated_at on every message (not just the first) so
actively-used conversations rise to the top of the list endpoint's
existing -updated_at sort. Refresh the sidebar after every send so the
just-used conversation reorders live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 01:33:44 -04:00
Ryan Chen 4fe5b8daea Persist streamed assistant answers even if the client disconnects
The first message in a new conversation navigated to /c/:id, which remounted
ChatScreen (two separate routes) and aborted the in-flight /stream-query fetch.
Server-side that surfaced as asyncio.CancelledError, which isn't an Exception,
so the except branch missed it and skipped the add_message_to_conversation
save — the fully-streamed answer was never persisted.

Backend: handle CancelledError and persist via a detached, shielded task so a
disconnect at any point (navigation, tab close, network blip) still commits the
answer. Frontend: serve "/" and "/c/:id" from a single catch-all ChatScreen
route so first-message navigation no longer remounts and aborts the stream;
derive the conversation id via useMatch instead of useParams.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 01:33:44 -04:00
ryan 53f76f58ea Merge pull request 'Stream assistant answers token-by-token; mobile drawer + conversation pagination' (#39) from mobile-drawer-conversation-pagination into main
Reviewed-on: #39
2026-09-09 23:15:14 -04:00
Ryan Chen b918522816 Stream assistant answers token-by-token over SSE
The /stream-query endpoint previously emitted only tool-lifecycle events plus
one final complete message, so the answer still appeared all at once. Handle
on_chat_model_stream events to emit incremental `content` deltas, accumulate
them for persistence fallback, and consume them on the frontend to build a live
assistant bubble (reconciled with the authoritative final text). Hide the
redundant loading indicator once tokens start arriving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 23:14:08 -04:00
ryan eb8cd19e46 Merge pull request 'Mobile slide-in drawer + conversation pagination' (#38) from mobile-drawer-conversation-pagination into main
Reviewed-on: #38
2026-09-09 22:32:37 -04:00
Ryan Chen 09dd983c4e Replace inline mobile menu with slide-in drawer and paginate conversations
Rework mobile navigation into a proper left slide-in drawer with a dimmed
backdrop, sharing a new SidebarContent component with the desktop sidebar so
mobile users can reach Admin/Scheduler/Sign out. The sidebar now loads only the
10 most recent conversations; a dedicated /conversations page browses the full
list with server-side search and "Load more" pagination.

- Backend: limit/offset/search query params on GET /api/conversation/
- SidebarContent shared by desktop sidebar and mobile drawer
- useConversationBrowser hook powers the dedicated page
- Remove duplicated inline conversation lists from ChatScreen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 22:32:19 -04:00
ryan 35a2e10bc8 Merge pull request 'Render assistant replies as full-width text instead of bubbles' (#37) from conversation-routing into main
Reviewed-on: #37
2026-09-09 22:20:29 -04:00
Ryan Chen 530a90f237 Render assistant replies as full-width text instead of bubbles
Strip the card, border, shadow, and accent bar from AnswerBubble so
Simba's replies read like Claude/ChatGPT — plain full-width markdown on
the page. User messages stay bubbled for turn contrast. Widen the reading
column to max-w-3xl with roomier spacing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 22:19:58 -04:00
ryan bde4a994b7 Merge pull request 'Add per-conversation URLs with client-side routing' (#36) from conversation-routing into main
Reviewed-on: #36
2026-09-09 22:12:06 -04:00
Ryan Chen 6a62db2e85 Add per-conversation URLs with client-side routing
Give each conversation a deep-linkable URL (/c/:conversationId) so threads
survive refresh, support browser back/forward, and are shareable within a
session. The app was already an SPA but had no router.

- Add react-router-dom; wrap the app in BrowserRouter and define routes
  ("/" for the empty/new state, "/c/:conversationId" for a thread)
- Make the URL the source of truth for selection: selectedConversation is
  derived from the :conversationId param; create/delete navigate instead of
  mutating local selection state
- Load a thread's messages in a URL-keyed effect that clears immediately for
  an instant switch, with a stale-response guard
- Preserve the optimistic message + in-flight stream when the first message
  lazily creates a conversation and navigates into it (skip-ref keyed on the
  new id, guarded against the null/home-route collision)

No backend changes: app.py already serves index.html for unmatched paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 22:10:47 -04:00
ryan 47db945393 Merge pull request 'Add PetKit Eversweet Ultra water fountain support to SimbaKit' (#34) from eversweet-ultra-support into main
Reviewed-on: #34
2026-09-09 21:34:53 -04:00
ryan 6293d9df0a Merge pull request 'Clean up frontend chat interactions and scope conversation access to owner' (#35) from frontend-interactions-cleanup into main
Reviewed-on: #35
2026-09-09 21:34:42 -04:00
Ryan Chen 32285891fc Clean up frontend chat interactions and scope conversation access to owner
Frontend:
- Add stop-generation button (wire up the unused AbortController)
- Surface stream/network errors in a dismissible banner instead of console
- Add rename and delete for conversations via a per-thread menu
- Make simba mode a single controlled source; fix miscast keydown handler
- Auto-grow the message textarea; remove dead ConversationMenu component

Backend:
- Scope get/query/stream-query/rename/delete conversation routes to the
  authenticated owner via a shared _get_owned_conversation helper (fixes IDOR)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 09:53:53 -04:00
Ryan Chen 7aef6484c3 Render GFM tables in chat replies
Enable remark-gfm in AnswerBubble so pipe-table syntax parses instead
of showing as raw text, and add table styling to markdown-content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 09:38:54 -04:00
28 changed files with 1845 additions and 513 deletions
+56 -21
View File
@@ -4,33 +4,39 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
SimbaRAG is a RAG (Retrieval-Augmented Generation) conversational AI system for querying information about Simba (a cat). It ingests documents from Paperless-NGX, stores embeddings in PostgreSQL via pgvector, and uses LLMs (Ollama or OpenAI) to answer questions.
SimbaRAG is a RAG (Retrieval-Augmented Generation) conversational AI system for querying information about Simba (a cat) and the household. It ingests documents from Paperless-NGX, Obsidian, and Immich, stores embeddings in PostgreSQL via pgvector (Chroma client), uses LLMs (Ollama or OpenAI) to answer questions with a tool-using LangChain agent, and exposes chat over web, iMessage, and WhatsApp.
## Commands
### Development
```bash
# Start environment
docker compose up --build
# Start environment (raggr runs as raggr-blue/raggr-green behind compose profiles)
docker compose --profile blue up --build
# View logs
docker compose logs -f raggr
# View logs (Makefile resolves the active color)
make logs
# Reindex vector store (all sources / Obsidian only)
make reindex
make reindex-obsidian
```
### Database Migrations (Aerich/Tortoise ORM)
```bash
# Generate migration (must run in Docker with DB access)
docker compose exec raggr aerich migrate --name describe_change
make migrate-new
# Apply migrations (auto-runs on startup, manual if needed)
docker compose exec raggr aerich upgrade
make migrate
# View migration history
docker compose exec raggr aerich history
make migrate-history
```
Migrations must be backward-compatible: blue-green deploys run `aerich upgrade` in the new container while the old code still serves.
### Frontend
```bash
@@ -42,9 +48,10 @@ yarn dev # Dev server (rarely needed, backend serves frontend)
### Production
Zero-downtime blue-green deploy: `raggr-blue` (port 8080) and `raggr-green` (port 8081) alternate behind Caddy health checks. See `docs/deployment.md`.
```bash
docker compose build raggr
docker compose up -d
make redeploy # git pull + scripts/deploy.sh (build new color, wait healthy, stop old)
```
## Architecture
@@ -53,9 +60,11 @@ docker compose up -d
┌─────────────────────────────────────────────────────────────┐
│ Docker Compose │
├─────────────────────────────────────────────────────────────┤
│ raggr (port 8080) │ postgres (port 5432) │
│ raggr-blue/green (8080/8081) │ postgres (port 5432) │
│ ├── Quart backend │ PostgreSQL 16 + pgvector│
│ └── React frontend (served) │ │
│ ├── React frontend (served) │ │
│ └── Twilio webhooks (iMessage/ │ │
│ WhatsApp) │ │
└─────────────────────────────────────────────────────────────┘
```
@@ -63,33 +72,49 @@ docker compose up -d
- `app.py` - Quart application entry, serves API and static frontend
- `main.py` - RAG logic, document indexing, LLM interaction, LangChain agent
- `llm.py` - LLM client with Ollama primary, OpenAI fallback
- `aerich_config.py` - Database migration configuration
- `blueprints/` - API routes organized as Quart blueprints
- `users/` - OIDC auth, JWT tokens, RBAC with LDAP groups
- `conversation/` - Chat conversations and message history
- `rag/` - Document indexing endpoints (admin-only)
- `conversation/` - Chat conversations, message history, streamed answers
- `rag/` - Document indexing endpoints (admin-only); logic in `rag/logic.py`
- `email/` - Email integration (Mailgun webhooks, Fernet encryption, IMAP, parsing)
- `imessage/` - Twilio webhook receiver for iMessage (HMAC-validated, number allowlist)
- `whatsapp/` - Twilio webhook receiver for WhatsApp (TWIML replies, SendBlue send support)
- `scheduled_messages/` - Scheduled message delivery with background scheduler
- `config/` - Configuration modules
- `db.py` - Database setup
- `oidc_config.py` - OIDC authentication configuration
- `utils/` - Reusable utilities
- `utils/` - Reusable utilities and household integrations
- `chunker.py` - Document chunking for embeddings
- `cleaner.py` - PDF cleaning and summarization
- `image_process.py` - Image description with LLM
- `request.py` - Paperless-NGX API client
- `obsidian_service.py`, `mealie_service.py`, `ynab_service.py`, `s3_client.py`, `simbakit_service.py` - integrations with Obsidian notes, Mealie recipes, YNAB budgets, S3, and SimbaKit
- `scripts/` - Administrative and utility scripts
- `add_user.py` - Create users manually
- `user_message_stats.py` - User message statistics
- `manage_vectorstore.py` - Vector store management CLI
- `inspect_vector_store.py` - Inspect ChromaDB contents
- `query.py` - Query generation utilities
- `manage_vectorstore.py` / `inspect_vector_store.py` - Vector store CLI
- `index_immich.py` - Index Immich photos into the vector store
- `link_imessage.py` - Link iMessage contacts to users
- `deploy.sh` - Blue-green deployment script
- `migrations/` - Database migration files
- `tests/` - pytest test suite (`tests/unit/` covers email/crypto, YNAB, Obsidian, SimbaKit, rate limiting, user model)
**simbakit/** (standalone subproject):
- Self-hosted PetKit device poller + web dashboard for Simba's smart devices (feeder, litter box, fountain, purifier)
- Own `pyproject.toml`, `uv.lock`, and Dockerfile; Python 3.14 + uv
- Polls PetKit cloud API every 2 min into SQLite; Chart.js dashboard; docs in `simbakit/docs/`
- Queried from the main app via `utils/simbakit_service.py`
**Frontend** (`raggr-frontend/`):
- React 19 with Rsbuild bundler
- Tailwind CSS for styling
- Built to `dist/`, served by backend at `/`
- Key components: `ChatScreen`, `ConversationsPage`, `LoginScreen`, `AdminPanel`, `ScheduledMessagesPanel`, `ToolBubble` (agent tool-call display)
**Auth Flow**: LLDAP → Authelia (OIDC) → Backend JWT → Frontend localStorage
**Data Sources**: Paperless-NGX documents, Obsidian vault, Immich photos (indexed via `scripts/index_immich.py` / `make reindex`)
## Testing
Always run `make test` before pushing code to ensure all tests pass.
@@ -105,6 +130,7 @@ make test-cov # Run tests with coverage
- Use `@jwt_refresh_token_required` for authenticated endpoints
- Use `@admin_required` for admin-only endpoints (checks `lldap_admin` group)
- Tortoise ORM models in `blueprints/*/models.py`
- External integrations are service classes in `utils/*_service.py`
- Frontend API services in `raggr-frontend/src/api/`
## Environment Variables
@@ -112,6 +138,15 @@ make test-cov # Run tests with coverage
See `.env.example`. Key ones:
- `DATABASE_URL` - PostgreSQL connection
- `OIDC_*` - Authelia OIDC configuration
- `OLLAMA_URL` - Local LLM server
- `OPENAI_API_KEY` - Fallback LLM
- `LLAMA_SERVER_URL` / `LLAMA_MODEL_NAME` - Local Llama/Ollama LLM server
- `EMBEDDING_SERVER_URL` / `EMBEDDING_MODEL_NAME` - Embedding model
- `OPENAI_API_KEY` - Fallback LLM; `TAVILY_API_KEY` - web search tool
- `PAPERLESS_TOKEN` / `BASE_URL` - Document source
- `TWILIO_*`, `ALLOWED_IMESSAGE_NUMBERS`, `ALLOWED_WHATSAPP_NUMBERS` - iMessage/WhatsApp webhooks
- `MAILGUN_*`, `EMAIL_HMAC_SECRET`, `FERNET_KEY` - email integration
- `SENDBLUE_*` - SMS sending for scheduled messages
- `YNAB_*`, `MEALIE_*`, `OBSIDIAN_*`, `IMMICH_*`, `SIMBAKIT_URL` - household integrations
## Documentation
Project docs live in `docs/` (mkdocs): `deployment.md`, `development.md`, `authentication.md`, `MIGRATIONS.md`, `VECTORSTORE.md`, `TASKS.md`, `ynab_integration/`.
+39 -10
View File
@@ -1,36 +1,65 @@
.PHONY: deploy redeploy build up down restart logs migrate migrate-new frontend test
.PHONY: deploy redeploy build up down restart logs migrate migrate-new migrate-history frontend test test-cov reindex reindex-obsidian
# Blue-green: detect the running color (default blue when nothing is running)
ACTIVE_COLOR := $(shell docker compose ps --status running --services 2>/dev/null | grep -oE 'raggr-(blue|green)' | head -1 | cut -d- -f2)
ifeq ($(ACTIVE_COLOR),)
ACTIVE_COLOR := blue
endif
RAGGR := raggr-$(ACTIVE_COLOR)
# Both profiles enabled for commands that only touch existing containers
# (never use this with `up`, which would start both colors)
COMPOSE := docker compose --profile blue --profile green
# Build and deploy
deploy: build up
# Zero-downtime blue-green swap (see scripts/deploy.sh)
redeploy:
git pull && $(MAKE) down && $(MAKE) up
git pull && ./scripts/deploy.sh
build:
docker compose build raggr
docker compose --profile $(ACTIVE_COLOR) build $(RAGGR)
up:
docker compose up -d
docker compose --profile $(ACTIVE_COLOR) up -d
down:
docker compose down
$(COMPOSE) down
restart:
docker compose restart raggr
$(COMPOSE) restart $(RAGGR)
logs:
docker compose logs -f raggr
$(COMPOSE) logs -f $(RAGGR)
# Vector store reindexing
reindex:
$(COMPOSE) exec $(RAGGR) python -c "\
import asyncio; \
from blueprints.rag.logic import delete_all_documents, index_documents, index_obsidian_documents; \
delete_all_documents(); \
asyncio.run(index_documents()); \
asyncio.run(index_obsidian_documents()); \
print('Reindex complete')"
reindex-obsidian:
$(COMPOSE) exec $(RAGGR) python -c "\
import asyncio; \
from blueprints.rag.logic import delete_documents_by_metadata, index_obsidian_documents; \
delete_documents_by_metadata('source', 'obsidian'); \
asyncio.run(index_obsidian_documents()); \
print('Obsidian reindex complete')"
# Database migrations
migrate:
docker compose exec raggr aerich upgrade
$(COMPOSE) exec $(RAGGR) aerich upgrade
migrate-new:
@read -p "Migration name: " name; \
docker compose exec raggr aerich migrate --name $$name
$(COMPOSE) exec $(RAGGR) aerich migrate --name $$name
migrate-history:
docker compose exec raggr aerich history
$(COMPOSE) exec $(RAGGR) aerich history
# Tests
test:
+2 -2
View File
@@ -127,8 +127,8 @@ cp .env.example .env
# Development (local PostgreSQL only)
docker compose -f docker-compose.dev.yml up -d
# Or full Docker deployment
docker compose up -d
# Or full Docker deployment (app runs as raggr-blue; see docs/deployment.md)
docker compose --profile blue up -d
```
4. **Access the application**
+79
View File
@@ -0,0 +1,79 @@
# Streaming Implementation Summary
## Overview
The `/stream-query` endpoint uses Server-Sent Events (SSE) to give real-time feedback
during AI query processing: tool-execution status **and** token-by-token streaming of the
assistant's answer.
## Backend (`blueprints/conversation/__init__.py`)
### `POST /api/conversation/stream-query`
Streams SSE frames (`data: {json}\n\n`) by iterating `main_agent.astream_events(..., version="v2")`.
Event types emitted to the client:
- `tool_start` — a tool began (`{ "type": "tool_start", "tool": <name> }`), from `on_tool_start`.
- `tool_end` — a tool finished, from `on_tool_end`.
- `content` — an incremental answer chunk (`{ "type": "content", "delta": <text> }`), from
`on_chat_model_stream`. Chunks are accumulated server-side into `streamed`.
- `response` — the final, authoritative message (`{ "type": "response", "message": <full text> }`).
Sourced from `on_chain_end` (falling back to the accumulated `streamed` tokens). This is the
text that is persisted to the DB via `add_message_to_conversation(..., speaker="simba")`.
- `error` — an error message.
- The stream terminates with `data: [DONE]`.
The user's message is persisted before streaming begins; the assistant message is persisted once
the stream completes.
### `POST /api/conversation/query` (legacy, non-streaming)
Backwards-compatible endpoint that returns the complete response in one JSON payload
(`{ "response": <text> }`). Kept for compatibility; the frontend uses `/stream-query`.
## Frontend
### API service (`src/api/conversationService.ts`)
- `streamQuery(query, conversation_id, onEvent, signal?, imageKey?)` — POSTs to `/stream-query`
and drives the response through `_readSSEStream()`.
- `_readSSEStream()` — reads the `ReadableStream`, splits on `\n\n`, parses `data:` frames into
`SSEEvent` objects, and stops on `[DONE]`.
- `SSEEvent` union: `tool_start | tool_end | content | response | error`.
### Chat state (`src/hooks/useChat.ts`)
`sendMessage()` consumes the stream and maintains a unified `messages` array:
- `tool_start` → appends a `tool` bubble (mapped to a friendly label via `TOOL_MESSAGES`) and
resets the streaming pointer so any text after a tool call starts a fresh bubble.
- `content` → on the first delta, appends a new `simba` bubble and flips `streamingActiveRef` /
`streaming`; subsequent deltas append to that bubble's text.
- `response` → overwrites the live `simba` bubble with the authoritative message (guaranteeing
parity with what was persisted), or appends it if nothing streamed (e.g. an instant answer).
- `error` → surfaces a user-facing error.
A `streaming` boolean is exposed so the UI can hide the redundant loading indicator once tokens
start arriving.
### Components
- `ChatScreen.tsx` — renders `messages` (`ToolBubble` / `AnswerBubble` / `QuestionBubble`) and
shows the standalone loading-dots `AnswerBubble` only before the first token
(`(isLoading && !streaming) || messagesLoading`).
- `AnswerBubble.tsx` — renders the assistant message as markdown, or animated loading dots when
`loading` is set.
- `ToolBubble.tsx` — renders tool-status messages as inline badges.
## User Experience
1. User sends a message.
2. Loading dots appear.
3. Tool status bubbles appear as tools run (e.g. "Searching Simba's records...").
4. The answer streams in word-by-word into a single assistant bubble.
5. On completion the bubble is reconciled with the persisted final text.
## Testing
```bash
# Backend
make test # or: .venv/bin/python -m pytest tests/
# Frontend
cd raggr-frontend && yarn build
# Full stack (manual)
docker compose up --build
```
+10
View File
@@ -144,6 +144,16 @@ async def serve_react_app(path):
return await render_template("index.html")
# Health check used by the Docker healthcheck and Caddy's active health checks
@app.route("/api/health", methods=["GET"])
async def health():
try:
await Tortoise.get_connection("default").execute_query("SELECT 1")
except Exception:
return jsonify({"status": "unhealthy"}), 503
return jsonify({"status": "ok"})
@app.route("/api/messages", methods=["GET"])
@jwt_refresh_token_required
async def get_messages():
+108 -12
View File
@@ -1,3 +1,4 @@
import asyncio
import json
import logging
import uuid
@@ -18,7 +19,6 @@ from utils.s3_client import upload_image as s3_upload_image
from .agents import main_agent
from .logic import (
add_message_to_conversation,
get_conversation_by_id,
)
from .memory import get_memories_for_user
from .models import (
@@ -32,9 +32,26 @@ conversation_blueprint = Blueprint(
"conversation_api", __name__, url_prefix="/api/conversation"
)
# Detached persistence tasks. A strong reference keeps them from being garbage
# collected mid-flight when the SSE stream is cancelled by a client disconnect.
_persist_tasks: set[asyncio.Task] = set()
def _persist_in_background(coro) -> asyncio.Task:
task = asyncio.create_task(coro)
_persist_tasks.add(task)
task.add_done_callback(_persist_tasks.discard)
return task
_SYSTEM_PROMPT = SIMBA_SYSTEM_PROMPT
async def _get_owned_conversation(conversation_id, user_id) -> Conversation | None:
"""Fetch a conversation only if it belongs to the given user."""
return await Conversation.get_or_none(id=conversation_id, user_id=user_id)
async def _build_system_prompt_with_memories(user_id: str) -> str:
"""Append user memories to the base system prompt."""
memories = await get_memories_for_user(user_id)
@@ -82,7 +99,9 @@ async def query():
data = await request.get_json()
query = data.get("query")
conversation_id = data.get("conversation_id")
conversation = await get_conversation_by_id(conversation_id)
conversation = await _get_owned_conversation(conversation_id, current_user_uuid)
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
await conversation.fetch_related("messages")
await add_message_to_conversation(
conversation=conversation,
@@ -159,7 +178,9 @@ async def stream_query():
query_text = data.get("query")
conversation_id = data.get("conversation_id")
image_key = data.get("image_key")
conversation = await get_conversation_by_id(conversation_id)
conversation = await _get_owned_conversation(conversation_id, current_user_uuid)
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
await conversation.fetch_related("messages")
await add_message_to_conversation(
conversation=conversation,
@@ -191,6 +212,16 @@ async def stream_query():
async def event_generator():
final_message = None
streamed = ""
async def persist(text: str) -> None:
await add_message_to_conversation(
conversation=conversation,
message=text,
speaker="simba",
user=user,
)
try:
async for event in main_agent.astream_events(
payload, version="v2", config=agent_config
@@ -203,6 +234,13 @@ async def stream_query():
elif event_type == "on_tool_end":
yield f"data: {json.dumps({'type': 'tool_end', 'tool': event['name']})}\n\n"
elif event_type == "on_chat_model_stream":
chunk = event.get("data", {}).get("chunk")
content = getattr(chunk, "content", None)
if isinstance(content, str) and content:
streamed += content
yield f"data: {json.dumps({'type': 'content', 'delta': content})}\n\n"
elif event_type == "on_chain_end":
output = event.get("data", {}).get("output")
if isinstance(output, dict):
@@ -213,16 +251,24 @@ async def stream_query():
if isinstance(content, str) and content:
final_message = content
except asyncio.CancelledError:
# The client disconnected mid-stream (e.g. a route change aborts the
# fetch). CancelledError is not an Exception, so without this branch
# persistence below would be skipped and the answer lost. Save what
# was generated in a detached task, then honor the cancellation.
text = final_message or streamed
if text:
_persist_in_background(persist(text))
raise
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
final_message = final_message or streamed
if final_message:
await add_message_to_conversation(
conversation=conversation,
message=final_message,
speaker="simba",
user=user,
)
# Persist via a detached, shielded task so a disconnect at this
# point (after generation completes) still commits the answer.
task = _persist_in_background(persist(final_message))
await asyncio.shield(task)
yield f"data: {json.dumps({'type': 'response', 'message': final_message})}\n\n"
else:
yield f"data: {json.dumps({'type': 'error', 'message': 'No response generated'})}\n\n"
@@ -243,7 +289,10 @@ async def stream_query():
@conversation_blueprint.route("/<conversation_id>")
@jwt_refresh_token_required
async def get_conversation(conversation_id: str):
conversation = await Conversation.get(id=conversation_id)
user_uuid = get_jwt_identity()
conversation = await _get_owned_conversation(conversation_id, user_uuid)
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
await conversation.fetch_related("messages")
# Manually serialize the conversation with messages
@@ -288,7 +337,54 @@ async def create_conversation():
async def get_all_conversations():
user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=user_uuid)
conversations = Conversation.filter(user=user).order_by("-updated_at")
serialized_conversations = await PydListConversation.from_queryset(conversations)
query = Conversation.filter(user=user)
search = request.args.get("search", "").strip()
if search:
query = query.filter(name__icontains=search)
query = query.order_by("-updated_at")
# `limit` is optional: when omitted we return the full list (backward
# compatible with any non-frontend callers). `offset` only applies with it.
limit = request.args.get("limit", type=int)
if limit is not None:
offset = request.args.get("offset", default=0, type=int)
query = query.offset(offset).limit(limit)
serialized_conversations = await PydListConversation.from_queryset(query)
return jsonify(serialized_conversations.model_dump())
@conversation_blueprint.patch("/<conversation_id>")
@jwt_refresh_token_required
async def rename_conversation(conversation_id: str):
user_uuid = get_jwt_identity()
body = await request.get_json()
name = (body or {}).get("name", "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
conversation = await _get_owned_conversation(conversation_id, user_uuid)
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
conversation.name = name[:255]
await conversation.save()
serialized_conversation = await PydConversation.from_tortoise_orm(conversation)
return jsonify(serialized_conversation.model_dump())
@conversation_blueprint.delete("/<conversation_id>")
@jwt_refresh_token_required
async def delete_conversation(conversation_id: str):
user_uuid = get_jwt_identity()
conversation = await _get_owned_conversation(conversation_id, user_uuid)
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
await conversation.delete()
return jsonify({"success": True})
+10 -1
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timezone
import tortoise.exceptions
import blueprints.users.models
@@ -19,10 +21,17 @@ async def add_message_to_conversation(
) -> ConversationMessage:
print(conversation, message, speaker)
# Bump updated_at on every message so conversations sort by last activity.
# `update_fields` bypasses the auto_now recompute, so set it explicitly.
update_fields = ["updated_at"]
conversation.updated_at = datetime.now(timezone.utc)
# Name the conversation after the first user message
if speaker == "user" and not await conversation.messages.all().exists():
conversation.name = message[:100]
await conversation.save()
update_fields.append("name")
await conversation.save(update_fields=update_fields)
message = await ConversationMessage.create(
text=message,
+87 -68
View File
@@ -1,5 +1,80 @@
version: "3.8"
x-raggr-common: &raggr-common
build:
context: .
dockerfile: Dockerfile
image: torrtle/simbarag:latest
environment:
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN}
- BASE_URL=${BASE_URL}
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- EMBEDDING_SERVER_URL=${EMBEDDING_SERVER_URL}
- EMBEDDING_MODEL_NAME=${EMBEDDING_MODEL_NAME}
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
- LLAMA_SERVER_URL=${LLAMA_SERVER_URL}
- LLAMA_MODEL_NAME=${LLAMA_MODEL_NAME}
- OIDC_ISSUER=${OIDC_ISSUER}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI}
- OIDC_USE_DISCOVERY=${OIDC_USE_DISCOVERY:-true}
- DATABASE_URL=${DATABASE_URL:-postgres://raggr:changeme@postgres:5432/raggr}
- TAVILY_API_KEY=${TAVILIY_API_KEY}
- YNAB_ACCESS_TOKEN=${YNAB_ACCESS_TOKEN}
- YNAB_BUDGET_ID=${YNAB_BUDGET_ID}
- MEALIE_BASE_URL=${MEALIE_BASE_URL}
- MEALIE_API_TOKEN=${MEALIE_API_TOKEN}
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
- TWILIO_WHATSAPP_NUMBER=${TWILIO_WHATSAPP_NUMBER}
- ALLOWED_WHATSAPP_NUMBERS=${ALLOWED_WHATSAPP_NUMBERS}
- TWILIO_SIGNATURE_VALIDATION=${TWILIO_SIGNATURE_VALIDATION:-true}
- TWILIO_WEBHOOK_URL=${TWILIO_WEBHOOK_URL:-}
- OBSIDIAN_EMAIL=${OBSIDIAN_EMAIL}
- OBSIDIAN_PASSWORD=${OBSIDIAN_PASSWORD}
- OBSIDIAN_VAULT_ID=${OBSIDIAN_VAULT_ID}
- OBSIDIAN_E2E_PASSWORD=${OBSIDIAN_E2E_PASSWORD}
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
- OBSIDIAN_CONTINUOUS_SYNC=${OBSIDIAN_CONTINUOUS_SYNC:-false}
- OBSIDIAN_VAULT_PATH=${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-asksimba-images}
- S3_REGION=${S3_REGION:-garage}
- OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11434}
- FERNET_KEY=${FERNET_KEY}
- GOOGLE_CALENDAR_ENABLED=${GOOGLE_CALENDAR_ENABLED:-}
- GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=${GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE:-/app/config/gws-credentials.json}
- MAILGUN_API_KEY=${MAILGUN_API_KEY}
- MAILGUN_DOMAIN=${MAILGUN_DOMAIN}
- MAILGUN_WEBHOOK_SIGNING_KEY=${MAILGUN_WEBHOOK_SIGNING_KEY}
- MAILGUN_SIGNATURE_VALIDATION=${MAILGUN_SIGNATURE_VALIDATION:-true}
- EMAIL_RATE_LIMIT_MAX=${EMAIL_RATE_LIMIT_MAX:-5}
- EMAIL_RATE_LIMIT_WINDOW=${EMAIL_RATE_LIMIT_WINDOW:-300}
- SENDBLUE_API_KEY=${SENDBLUE_API_KEY}
- SENDBLUE_API_SECRET=${SENDBLUE_API_SECRET}
- SENDBLUE_FROM_NUMBER=${SENDBLUE_FROM_NUMBER}
- SENDBLUE_WEBHOOK_SECRET=${SENDBLUE_WEBHOOK_SECRET}
- SENDBLUE_SIGNATURE_VALIDATION=${SENDBLUE_SIGNATURE_VALIDATION:-true}
- ALLOWED_IMESSAGE_NUMBERS=${ALLOWED_IMESSAGE_NUMBERS}
- SIMBAKIT_URL=${SIMBAKIT_URL:-http://simbakit:8585}
depends_on:
postgres:
condition: service_healthy
volumes:
- ./obvault:/app/data/obsidian
- ./credentials.json:/app/config/gws-credentials.json:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
interval: 5s
timeout: 5s
retries: 3
start_period: 60s
restart: unless-stopped
services:
postgres:
image: pgvector/pgvector:pg16
@@ -19,76 +94,20 @@ services:
retries: 5
restart: unless-stopped
raggr:
build:
context: .
dockerfile: Dockerfile
image: torrtle/simbarag:latest
# Blue-green deployment: exactly one of raggr-blue/raggr-green serves at a
# time; Caddy health-checks both ports and routes to the healthy one.
# Use scripts/deploy.sh (make redeploy) to swap colors with zero downtime.
raggr-blue:
<<: *raggr-common
profiles: ["blue"]
ports:
- "8080:8080"
environment:
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN}
- BASE_URL=${BASE_URL}
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- EMBEDDING_SERVER_URL=${EMBEDDING_SERVER_URL}
- EMBEDDING_MODEL_NAME=${EMBEDDING_MODEL_NAME}
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
- LLAMA_SERVER_URL=${LLAMA_SERVER_URL}
- LLAMA_MODEL_NAME=${LLAMA_MODEL_NAME}
- OIDC_ISSUER=${OIDC_ISSUER}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI}
- OIDC_USE_DISCOVERY=${OIDC_USE_DISCOVERY:-true}
- DATABASE_URL=${DATABASE_URL:-postgres://raggr:changeme@postgres:5432/raggr}
- TAVILY_API_KEY=${TAVILIY_API_KEY}
- YNAB_ACCESS_TOKEN=${YNAB_ACCESS_TOKEN}
- YNAB_BUDGET_ID=${YNAB_BUDGET_ID}
- MEALIE_BASE_URL=${MEALIE_BASE_URL}
- MEALIE_API_TOKEN=${MEALIE_API_TOKEN}
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
- TWILIO_WHATSAPP_NUMBER=${TWILIO_WHATSAPP_NUMBER}
- ALLOWED_WHATSAPP_NUMBERS=${ALLOWED_WHATSAPP_NUMBERS}
- TWILIO_SIGNATURE_VALIDATION=${TWILIO_SIGNATURE_VALIDATION:-true}
- TWILIO_WEBHOOK_URL=${TWILIO_WEBHOOK_URL:-}
- OBSIDIAN_EMAIL=${OBSIDIAN_EMAIL}
- OBSIDIAN_PASSWORD=${OBSIDIAN_PASSWORD}
- OBSIDIAN_VAULT_ID=${OBSIDIAN_VAULT_ID}
- OBSIDIAN_E2E_PASSWORD=${OBSIDIAN_E2E_PASSWORD}
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
- OBSIDIAN_CONTINUOUS_SYNC=${OBSIDIAN_CONTINUOUS_SYNC:-false}
- OBSIDIAN_VAULT_PATH=${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-asksimba-images}
- S3_REGION=${S3_REGION:-garage}
- OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11434}
- FERNET_KEY=${FERNET_KEY}
- GOOGLE_CALENDAR_ENABLED=${GOOGLE_CALENDAR_ENABLED:-}
- GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=${GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE:-/app/config/gws-credentials.json}
- MAILGUN_API_KEY=${MAILGUN_API_KEY}
- MAILGUN_DOMAIN=${MAILGUN_DOMAIN}
- MAILGUN_WEBHOOK_SIGNING_KEY=${MAILGUN_WEBHOOK_SIGNING_KEY}
- MAILGUN_SIGNATURE_VALIDATION=${MAILGUN_SIGNATURE_VALIDATION:-true}
- EMAIL_RATE_LIMIT_MAX=${EMAIL_RATE_LIMIT_MAX:-5}
- EMAIL_RATE_LIMIT_WINDOW=${EMAIL_RATE_LIMIT_WINDOW:-300}
- SENDBLUE_API_KEY=${SENDBLUE_API_KEY}
- SENDBLUE_API_SECRET=${SENDBLUE_API_SECRET}
- SENDBLUE_FROM_NUMBER=${SENDBLUE_FROM_NUMBER}
- SENDBLUE_WEBHOOK_SECRET=${SENDBLUE_WEBHOOK_SECRET}
- SENDBLUE_SIGNATURE_VALIDATION=${SENDBLUE_SIGNATURE_VALIDATION:-true}
- ALLOWED_IMESSAGE_NUMBERS=${ALLOWED_IMESSAGE_NUMBERS}
- SIMBAKIT_URL=${SIMBAKIT_URL:-http://simbakit:8585}
depends_on:
postgres:
condition: service_healthy
volumes:
- ./obvault:/app/data/obsidian
- ./credentials.json:/app/config/gws-credentials.json:ro
restart: unless-stopped
raggr-green:
<<: *raggr-common
profiles: ["green"]
ports:
- "8081:8080"
simbakit:
build:
+76 -8
View File
@@ -96,20 +96,88 @@ The dev environment:
- Rebuilds frontend on changes
- Auto-applies migrations on startup
### Production
### Production (zero-downtime blue-green)
The app runs as one of two identical compose services: `raggr-blue`
(host port 8080) or `raggr-green` (host port 8081), gated behind compose
profiles so only one color runs at a time. Caddy health-checks both ports and
routes traffic to whichever is healthy.
```bash
# Build and deploy
docker compose build raggr
docker compose up -d
# Pull latest and swap colors with zero downtime
make redeploy
# View logs
docker compose logs -f raggr
# Or run the swap directly
./scripts/deploy.sh
# Verify migrations applied
docker compose exec raggr aerich history
# View logs / exec into the active color (Makefile resolves the color)
make logs
make migrate-history
```
`scripts/deploy.sh` does the following:
1. Detects the running color via `docker compose ps`.
2. Builds the inactive color.
3. Starts it with `docker compose up -d --wait`, which blocks until the
container healthcheck (`GET /api/health`) passes.
4. If the new color never becomes healthy, it is stopped and the old color
keeps serving (the script exits non-zero).
5. Otherwise the old color is stopped with a 30s grace period so in-flight
SSE streams can finish, then removed.
To target a color manually, enable its profile:
```bash
docker compose --profile blue up -d raggr-blue
docker compose --profile blue --profile green ps
```
#### Caddy configuration
Caddy needs both upstreams with active health checks:
```caddyfile
ask.torrtle.co {
reverse_proxy host.docker.internal:8080 host.docker.internal:8081 {
lb_policy first # prefer blue when both are healthy
lb_try_duration 5s # retry on the other upstream during the swap
lb_try_interval 250ms
health_uri /api/health
health_interval 2s
health_timeout 2s
fail_duration 10s
}
}
```
#### One-time cutover from the old `raggr` service
The first deploy after this change lands needs manual steps, because the old
`raggr` container still holds port 8080 and the deploy script doesn't know
about it:
```bash
# 1. Update the Caddy config to the two-upstream block below and reload Caddy
# 2. On the server:
git pull
docker compose --profile green up -d --build --wait raggr-green # port 8081, no conflict
docker stop -t 30 $(docker ps -q --filter name=raggr-1) # old raggr container
docker rm $(docker ps -aq --filter name=raggr-1)
# From here on, `make redeploy` handles swaps (green is now the active color)
```
#### Blue-green caveats
- **Migrations must be backward-compatible.** The new color runs
`aerich upgrade` on startup while the old color is still serving the
previous code. Additive changes (new tables/columns) are safe; destructive
changes (drop/rename a column the old code reads) need a two-step deploy:
ship code that stops using the column first, then drop it in a later deploy.
- **Obsidian sync overlap.** During the ~30s swap window both containers run
pull-only `ob sync` against the shared `./obvault` mount. Both are
read-only pulls, so this is low risk, but avoid deploying mid-reindex.
### Fresh Deploy (New Database)
On first deploy with an empty database, `startup-dev.sh` runs `aerich init-db` instead of `aerich upgrade`. This creates all tables from the current models.
+2
View File
@@ -20,6 +20,8 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.3",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"watch": "^1.0.2"
},
+32 -1
View File
@@ -60,7 +60,7 @@ body {
::-webkit-scrollbar-thumb { background: var(--color-sand); border-radius: 99px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-warm-gray); }
/* ── Markdown in answer bubbles ─────────────────────── */
/* ── Markdown in assistant answers ──────────────────── */
.markdown-content p { margin: 0.5em 0; line-height: 1.7; }
.markdown-content p:first-child { margin-top: 0; }
.markdown-content p:last-child { margin-bottom: 0; }
@@ -117,6 +117,29 @@ body {
.markdown-content strong { font-weight: 700; }
.markdown-content em { font-style: italic; }
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin: 0.75em 0;
font-size: 0.9em;
display: block;
overflow-x: auto;
}
.markdown-content th,
.markdown-content td {
border: 1px solid var(--color-sand-light);
padding: 0.4em 0.6em;
text-align: left;
vertical-align: top;
}
.markdown-content thead th {
background: var(--color-sand-light);
font-weight: 700;
}
.markdown-content tbody tr:nth-child(even) {
background: color-mix(in srgb, var(--color-sand-light) 35%, transparent);
}
/* ── Animations ─────────────────────────────────────── */
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(10px); }
@@ -126,6 +149,14 @@ body {
animation: fadeSlideUp 0.3s ease-out forwards;
}
@keyframes drawer-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
.animate-drawer-in {
animation: drawer-in 0.25s ease-out;
}
@keyframes catPulse {
0%, 80%, 100% { opacity: 0.25; transform: scale(0.75); }
40% { opacity: 1; transform: scale(1); }
+18 -7
View File
@@ -1,6 +1,8 @@
import { Route, Routes } from "react-router-dom";
import "./App.css";
import { AuthProvider } from "./contexts/AuthContext";
import { ChatScreen } from "./components/ChatScreen";
import { ConversationsPage } from "./components/ConversationsPage";
import { LoginScreen } from "./components/LoginScreen";
import { useAuthCheck } from "./hooks/useAuthCheck";
import catIcon from "./assets/cat.png";
@@ -23,14 +25,23 @@ const AppContainer = () => {
);
}
if (!isAuthenticated) {
return <LoginScreen setAuthenticated={setAuthenticated} />;
}
return (
<>
{isAuthenticated ? (
<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />
) : (
<LoginScreen setAuthenticated={setAuthenticated} />
)}
</>
<Routes>
<Route path="/conversations" element={<ConversationsPage />} />
{/* One ChatScreen instance serves both the home ("/") and per-conversation
("/c/:id") routes. Using a single catch-all route keeps that instance
mounted across the navigate() that fires when the first message creates
a conversation — a remount here would abort the in-flight SSE stream
(and drop the answer before it's persisted). */}
<Route
path="*"
element={<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />}
/>
</Routes>
);
};
+45 -10
View File
@@ -3,6 +3,7 @@ import { userService } from "./userService";
export type SSEEvent =
| { type: "tool_start"; tool: string }
| { type: "tool_end"; tool: string }
| { type: "content"; delta: string }
| { type: "response"; message: string }
| { type: "error"; message: string };
@@ -25,18 +26,10 @@ interface Conversation {
user_id?: string;
}
interface QueryRequest {
query: string;
}
interface QueryResponse {
response: string;
}
interface CreateConversationRequest {
user_id: string;
}
class ConversationService {
private baseUrl = "/api";
private conversationBaseUrl = "/api/conversation";
@@ -107,9 +100,51 @@ class ConversationService {
return await response.json();
}
async getAllConversations(): Promise<Conversation[]> {
async renameConversation(
conversationId: string,
name: string,
): Promise<Conversation> {
const response = await userService.fetchWithRefreshToken(
`${this.conversationBaseUrl}/`,
`${this.conversationBaseUrl}/${conversationId}`,
{
method: "PATCH",
body: JSON.stringify({ name }),
},
);
if (!response.ok) {
throw new Error("Failed to rename conversation");
}
return await response.json();
}
async deleteConversation(conversationId: string): Promise<void> {
const response = await userService.fetchWithRefreshToken(
`${this.conversationBaseUrl}/${conversationId}`,
{
method: "DELETE",
},
);
if (!response.ok) {
throw new Error("Failed to delete conversation");
}
}
async getAllConversations(params?: {
limit?: number;
offset?: number;
search?: string;
}): Promise<Conversation[]> {
const search = new URLSearchParams();
if (params?.limit != null) search.set("limit", String(params.limit));
if (params?.offset != null) search.set("offset", String(params.offset));
if (params?.search) search.set("search", params.search);
const qs = search.toString();
const response = await userService.fetchWithRefreshToken(
`${this.conversationBaseUrl}/${qs ? `?${qs}` : ""}`,
{
method: "GET",
},
+36 -26
View File
@@ -1,6 +1,7 @@
import React from "react";
import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import { cn } from "../lib/utils";
import remarkGfm from "remark-gfm";
import { Copy, Check } from "lucide-react";
type AnswerBubbleProps = {
text: string;
@@ -8,32 +9,41 @@ type AnswerBubbleProps = {
};
export const AnswerBubble = React.memo(({ text, loading }: AnswerBubbleProps) => {
return (
<div className="flex justify-start message-enter">
<div
className={cn(
"max-w-[78%] rounded-3xl rounded-bl-md",
"bg-warm-white border border-sand-light/70",
"shadow-sm shadow-sand/30",
"overflow-hidden",
)}
>
<div className="h-0.5 w-full bg-gradient-to-r from-amber-soft via-amber-glow/50 to-transparent" />
const [copied, setCopied] = useState(false);
<div className="px-4 py-3">
{loading ? (
<div className="flex items-center gap-1.5 py-1 px-1">
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
</div>
) : (
<div className="markdown-content text-sm leading-relaxed text-charcoal">
<ReactMarkdown>{text}</ReactMarkdown>
</div>
)}
const handleCopy = async (event: React.MouseEvent) => {
event.stopPropagation();
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard unavailable (e.g. insecure context) — ignore
}
};
return (
<div className="message-enter">
{loading ? (
<div className="flex items-center gap-1.5 py-1">
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
</div>
</div>
) : (
<div className="group relative -mx-3 -my-2 rounded-xl px-3 py-2 transition-colors duration-150 hover:bg-amber-soft/10">
<div className="markdown-content text-[15px] leading-relaxed text-charcoal">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
</div>
<button
onClick={handleCopy}
aria-label={copied ? "Copied" : "Copy message"}
className="absolute top-0 right-0 flex h-7 w-7 items-center justify-center rounded-lg border border-sand-light/60 bg-warm-white text-warm-gray shadow-sm opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-charcoal hover:bg-cream-dark focus:opacity-100 focus:outline-none cursor-pointer"
>
{copied ? <Check size={14} className="text-green-600" /> : <Copy size={14} />}
</button>
</div>
)}
</div>
);
});
+106 -116
View File
@@ -1,10 +1,11 @@
import { useCallback, useState, useRef } from "react";
import { LogOut, Shield, Clock, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PanelLeftOpen, Menu, X } from "lucide-react";
import { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble";
import { ToolBubble } from "./ToolBubble";
import { MessageInput } from "./MessageInput";
import { ConversationList } from "./ConversationList";
import { SidebarContent } from "./SidebarContent";
import { AdminPanel } from "./AdminPanel";
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
import { cn } from "../lib/utils";
@@ -18,6 +19,7 @@ type ChatScreenProps = {
};
export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [simbaMode, setSimbaMode] = useState(false);
const [showConversations, setShowConversations] = useState(false);
@@ -39,8 +41,9 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
const {
conversations,
selectedConversation,
selectConversation,
createConversation,
renameConversation,
deleteConversation,
refreshConversations,
} = useConversations();
@@ -48,13 +51,16 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
const {
messages,
setMessages,
isLoading,
streaming,
messagesLoading,
pendingImage,
setPendingImage,
sendMessage,
stopGeneration,
error,
clearError,
} = useChat({
selectedConversation,
createConversation,
refreshConversations,
onSessionExpired,
@@ -65,18 +71,17 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
isLoadingRef.current = isLoading;
const handleSelectConversation = useCallback(
async (conversation: { title: string; id: string }) => {
(conversation: { title: string; id: string }) => {
setShowConversations(false);
const loaded = await selectConversation(conversation);
setMessages(loaded);
navigate(`/c/${conversation.id}`);
},
[selectConversation, setMessages],
[navigate],
);
const handleCreateNewConversation = useCallback(async () => {
await createConversation();
setMessages([]);
}, [createConversation, setMessages]);
const handleCreateNewConversation = useCallback(() => {
setShowConversations(false);
navigate("/");
}, [navigate]);
const handleQuestionSubmit = useCallback(() => {
sendMessage(query, simbaMode);
@@ -87,14 +92,20 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
setQuery(event.target.value);
}, []);
const handleKeyDown = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
if (kev.key === "Enter" && !kev.shiftKey) {
kev.preventDefault();
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleQuestionSubmit();
}
}, [handleQuestionSubmit]);
const handleToggleSimbaMode = useCallback(() => setSimbaMode((v) => !v), []);
const handleDeleteConversation = useCallback(
(id: string) => deleteConversation(id),
[deleteConversation],
);
const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]);
const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]);
@@ -104,6 +115,16 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
setAuthenticated(false);
};
const sidebarProps = {
conversations,
onCreateNewConversation: handleCreateNewConversation,
onSelectConversation: handleSelectConversation,
onRenameConversation: renameConversation,
onDeleteConversation: handleDeleteConversation,
selectedId: selectedConversation?.id,
isAdmin,
};
return (
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
{/* Desktop Sidebar */}
@@ -129,65 +150,41 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
/>
</div>
) : (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
<div className="flex items-center gap-2.5">
<img src={catIcon} alt="Simba" className="w-12 h-12" />
<h2
className="text-lg font-bold text-cream tracking-tight"
style={{ fontFamily: "var(--font-display)" }}
>
asksimba
</h2>
</div>
<button
onClick={() => setSidebarCollapsed(true)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
>
<PanelLeftClose size={15} />
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 py-3">
<ConversationList
conversations={conversations}
onCreateNewConversation={handleCreateNewConversation}
onSelectConversation={handleSelectConversation}
selectedId={selectedConversation?.id}
/>
</div>
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
{isAdmin && (
<>
<button
onClick={() => setShowAdminPanel(true)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<Shield size={14} />
<span>Admin</span>
</button>
<button
onClick={() => setShowScheduler(true)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<Clock size={14} />
<span>Scheduler</span>
</button>
</>
)}
<button
onClick={handleLogout}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<LogOut size={14} />
<span>Sign out</span>
</button>
</div>
</div>
<SidebarContent
{...sidebarProps}
onShowAdmin={() => setShowAdminPanel(true)}
onShowScheduler={() => setShowScheduler(true)}
onLogout={handleLogout}
onCollapse={() => setSidebarCollapsed(true)}
/>
)}
</aside>
{/* Mobile drawer */}
{showConversations && (
<div className="md:hidden fixed inset-0 z-50 flex">
<aside className="w-72 max-w-[80%] h-full bg-sidebar-bg shadow-xl animate-drawer-in">
<SidebarContent
{...sidebarProps}
onShowAdmin={() => {
setShowConversations(false);
setShowAdminPanel(true);
}}
onShowScheduler={() => {
setShowConversations(false);
setShowScheduler(true);
}}
onLogout={handleLogout}
onCloseDrawer={() => setShowConversations(false)}
/>
</aside>
<div
className="flex-1 bg-charcoal/40 backdrop-blur-sm"
onClick={() => setShowConversations(false)}
/>
</div>
)}
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
@@ -202,35 +199,17 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
asksimba
</h1>
</div>
<div className="flex items-center gap-2">
<button
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
onClick={() => setShowConversations((v) => !v)}
>
{showConversations ? <X size={16} /> : <Menu size={16} />}
</button>
<button
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
onClick={handleLogout}
>
<LogOut size={15} />
</button>
</div>
<button
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
onClick={() => setShowConversations(true)}
aria-label="Open menu"
>
<Menu size={16} />
</button>
</header>
{messages.length === 0 ? (
{messages.length === 0 && !messagesLoading ? (
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
{showConversations && (
<div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
<ConversationList
conversations={conversations}
onCreateNewConversation={handleCreateNewConversation}
onSelectConversation={handleSelectConversation}
selectedId={selectedConversation?.id}
variant="light"
/>
</div>
)}
<div className="relative">
<div className="absolute -inset-6 bg-amber-soft/20 rounded-full blur-3xl" />
<img src={catIcon} alt="Simba" className="relative w-36 h-36" />
@@ -247,30 +226,21 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
handleQueryChange={handleQueryChange}
handleKeyDown={handleKeyDown}
handleQuestionSubmit={handleQuestionSubmit}
setSimbaMode={setSimbaMode}
simbaMode={simbaMode}
onToggleSimbaMode={handleToggleSimbaMode}
isLoading={isLoading}
onStop={stopGeneration}
pendingImage={pendingImage}
onImageSelect={handleImageSelect}
onClearImage={handleClearImage}
autoFocus
/>
</div>
</div>
) : (
<>
<div className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-2xl mx-auto flex flex-col gap-3">
{showConversations && (
<div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
<ConversationList
conversations={conversations}
onCreateNewConversation={handleCreateNewConversation}
onSelectConversation={handleSelectConversation}
selectedId={selectedConversation?.id}
variant="light"
/>
</div>
)}
<div className="max-w-3xl mx-auto flex flex-col gap-6">
{messages.map((msg, index) => {
if (msg.speaker === "tool")
return <ToolBubble key={index} text={msg.text} />;
@@ -279,20 +249,40 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
return <QuestionBubble key={index} text={msg.text} image_key={msg.image_key} />;
})}
{isLoading && <AnswerBubble text="" loading={true} />}
{((isLoading && !streaming) || messagesLoading) && (
<AnswerBubble text="" loading={true} />
)}
{error && (
<div className="flex justify-center message-enter">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-50 border border-red-200 text-xs text-red-600">
<span>{error}</span>
<button
onClick={clearError}
className="text-red-400 hover:text-red-600 transition-colors cursor-pointer"
aria-label="Dismiss error"
>
<X size={13} />
</button>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
<footer className="border-t border-sand-light/40 bg-cream">
<div className="max-w-2xl mx-auto px-4 py-3">
<div className="max-w-3xl mx-auto px-4 py-3">
<MessageInput
query={query}
handleQueryChange={handleQueryChange}
handleKeyDown={handleKeyDown}
handleQuestionSubmit={handleQuestionSubmit}
setSimbaMode={setSimbaMode}
simbaMode={simbaMode}
onToggleSimbaMode={handleToggleSimbaMode}
isLoading={isLoading}
onStop={stopGeneration}
pendingImage={pendingImage}
onImageSelect={handleImageSelect}
onClearImage={handleClearImage}
@@ -1,4 +1,5 @@
import { Plus } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Check, MoreHorizontal, Pencil, Plus, Trash2, X } from "lucide-react";
import { cn } from "../lib/utils";
type Conversation = {
@@ -10,6 +11,8 @@ type ConversationProps = {
conversations: Conversation[];
onSelectConversation: (conversation: Conversation) => void;
onCreateNewConversation: () => void;
onRenameConversation?: (id: string, title: string) => void;
onDeleteConversation?: (id: string) => void;
selectedId?: string;
variant?: "dark" | "light";
};
@@ -18,9 +21,49 @@ export const ConversationList = ({
conversations,
onSelectConversation,
onCreateNewConversation,
onRenameConversation,
onDeleteConversation,
selectedId,
variant = "dark",
}: ConversationProps) => {
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const isDark = variant === "dark";
// Close any open row menu when clicking elsewhere.
useEffect(() => {
if (!menuOpenId && !confirmDeleteId) return;
const close = () => {
setMenuOpenId(null);
setConfirmDeleteId(null);
};
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, [menuOpenId, confirmDeleteId]);
useEffect(() => {
if (editingId) inputRef.current?.focus();
}, [editingId]);
const startRename = (conv: Conversation) => {
setMenuOpenId(null);
setEditingId(conv.id);
setEditValue(conv.title);
};
const commitRename = () => {
if (editingId && editValue.trim()) {
onRenameConversation?.(editingId, editValue.trim());
}
setEditingId(null);
};
const hasActions = Boolean(onRenameConversation || onDeleteConversation);
return (
<div className="flex flex-col gap-1">
<button
@@ -28,7 +71,7 @@ export const ConversationList = ({
className={cn(
"flex items-center gap-2 w-full px-3 py-2 rounded-xl",
"text-sm transition-all duration-150 cursor-pointer mb-1",
variant === "dark"
isDark
? "text-cream/60 hover:text-cream hover:bg-white/8"
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
@@ -39,24 +82,148 @@ export const ConversationList = ({
{conversations.map((conv) => {
const isActive = conv.id === selectedId;
const isEditing = editingId === conv.id;
if (isEditing) {
return (
<div
key={conv.id}
className={cn(
"flex items-center gap-1 px-1.5 py-1 rounded-xl",
isDark ? "bg-white/12" : "bg-cream-dark",
)}
>
<input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") setEditingId(null);
}}
className={cn(
"flex-1 min-w-0 bg-transparent px-1.5 py-1 text-sm rounded-lg",
"focus:outline-none",
isDark
? "text-cream placeholder:text-cream/40"
: "text-charcoal placeholder:text-warm-gray/50",
)}
/>
<button
onClick={commitRename}
className={cn(
"w-6 h-6 rounded-lg flex items-center justify-center shrink-0 cursor-pointer transition-colors",
isDark
? "text-cream/60 hover:text-cream hover:bg-white/10"
: "text-warm-gray hover:text-charcoal hover:bg-sand-light",
)}
>
<Check size={14} />
</button>
<button
onClick={() => setEditingId(null)}
className={cn(
"w-6 h-6 rounded-lg flex items-center justify-center shrink-0 cursor-pointer transition-colors",
isDark
? "text-cream/60 hover:text-cream hover:bg-white/10"
: "text-warm-gray hover:text-charcoal hover:bg-sand-light",
)}
>
<X size={14} />
</button>
</div>
);
}
return (
<button
key={conv.id}
onClick={() => onSelectConversation(conv)}
className={cn(
"w-full px-3 py-2 rounded-xl text-left",
"text-sm truncate transition-all duration-150 cursor-pointer",
variant === "dark"
? isActive
? "bg-white/12 text-cream font-medium"
: "text-cream/60 hover:text-cream hover:bg-white/8"
: isActive
? "bg-cream-dark text-charcoal font-medium"
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
<div key={conv.id} className="group relative flex items-center">
<button
onClick={() => onSelectConversation(conv)}
className={cn(
"flex-1 min-w-0 px-3 py-2 rounded-xl text-left",
"text-sm truncate transition-all duration-150 cursor-pointer",
hasActions && "pr-8",
isDark
? isActive
? "bg-white/12 text-cream font-medium"
: "text-cream/60 hover:text-cream hover:bg-white/8"
: isActive
? "bg-cream-dark text-charcoal font-medium"
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
{conv.title}
</button>
{hasActions && (
<button
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(null);
setMenuOpenId((prev) => (prev === conv.id ? null : conv.id));
}}
className={cn(
"absolute right-1.5 w-6 h-6 rounded-lg flex items-center justify-center cursor-pointer transition-all",
"opacity-0 group-hover:opacity-100 focus:opacity-100",
menuOpenId === conv.id && "opacity-100",
isDark
? "text-cream/50 hover:text-cream hover:bg-white/10"
: "text-warm-gray hover:text-charcoal hover:bg-sand-light",
)}
>
<MoreHorizontal size={15} />
</button>
)}
>
{conv.title}
</button>
{menuOpenId === conv.id && (
<div
onClick={(e) => e.stopPropagation()}
className={cn(
"absolute right-1.5 top-9 z-20 w-36 rounded-xl p-1 shadow-lg",
isDark
? "bg-sidebar-bg border border-white/10"
: "bg-warm-white border border-sand-light",
)}
>
{onRenameConversation && (
<button
onClick={() => startRename(conv)}
className={cn(
"flex items-center gap-2 w-full px-2.5 py-1.5 rounded-lg text-sm cursor-pointer transition-colors",
isDark
? "text-cream/70 hover:text-cream hover:bg-white/10"
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
<Pencil size={13} />
<span>Rename</span>
</button>
)}
{onDeleteConversation && (
<button
onClick={() => {
if (confirmDeleteId === conv.id) {
onDeleteConversation(conv.id);
setMenuOpenId(null);
setConfirmDeleteId(null);
} else {
setConfirmDeleteId(conv.id);
}
}}
className={cn(
"flex items-center gap-2 w-full px-2.5 py-1.5 rounded-lg text-sm cursor-pointer transition-colors",
"text-red-400 hover:bg-red-500/10",
)}
>
<Trash2 size={13} />
<span>
{confirmDeleteId === conv.id ? "Confirm delete" : "Delete"}
</span>
</button>
)}
</div>
)}
</div>
);
})}
</div>
@@ -1,24 +0,0 @@
type Conversation = {
title: string;
id: string;
};
type ConversationMenuProps = {
conversations: Conversation[];
};
export const ConversationMenu = ({ conversations }: ConversationMenuProps) => {
return (
<div className="absolute bg-white w-md rounded-md shadow-xl m-4 p-4">
<p className="py-2 px-4 rounded-md w-full text-xl font-bold">askSimba!</p>
{conversations.map((conversation) => (
<p
key={conversation.id}
className="py-2 px-4 rounded-md hover:bg-stone-200 w-full text-xl font-bold cursor-pointer"
>
{conversation.title}
</p>
))}
</div>
);
};
@@ -0,0 +1,94 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, Search } from "lucide-react";
import { ConversationList } from "./ConversationList";
import { useConversationBrowser } from "../hooks/useConversationBrowser";
import type { Conversation } from "../hooks/useConversations";
import { Input } from "./ui/input";
import catIcon from "../assets/cat.png";
export const ConversationsPage = () => {
const navigate = useNavigate();
const {
items,
search,
setSearch,
hasMore,
loading,
loadMore,
renameConversation,
deleteConversation,
} = useConversationBrowser();
const handleSelect = useCallback(
(conversation: Conversation) => navigate(`/c/${conversation.id}`),
[navigate],
);
const handleCreateNew = useCallback(() => navigate("/"), [navigate]);
return (
<div className="h-screen h-[100dvh] flex flex-col bg-cream overflow-hidden">
<header className="flex items-center gap-3 px-4 py-3 bg-warm-white border-b border-sand-light/60">
<button
onClick={() => navigate(-1)}
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
aria-label="Back"
>
<ArrowLeft size={16} />
</button>
<img src={catIcon} alt="Simba" className="w-10 h-10" />
<h1
className="text-base font-bold text-charcoal"
style={{ fontFamily: "var(--font-display)" }}
>
All conversations
</h1>
</header>
<div className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 py-6 flex flex-col gap-4">
<div className="relative">
<Search
size={15}
className="absolute left-3 top-1/2 -translate-y-1/2 text-warm-gray/60"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search conversations..."
className="h-10 pl-9"
/>
</div>
<div className="bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
<ConversationList
conversations={items}
onCreateNewConversation={handleCreateNew}
onSelectConversation={handleSelect}
onRenameConversation={renameConversation}
onDeleteConversation={deleteConversation}
variant="light"
/>
{items.length === 0 && !loading && (
<p className="text-center text-sm text-warm-gray/70 py-6">
{search ? "No conversations match your search." : "No conversations yet."}
</p>
)}
</div>
{hasMore && (
<button
onClick={loadMore}
disabled={loading}
className="self-center px-4 py-2 rounded-xl text-sm text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Loading..." : "Load more"}
</button>
)}
</div>
</div>
</div>
);
};
+159 -132
View File
@@ -1,160 +1,187 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { ArrowUp, ImagePlus, X } from "lucide-react";
import React, { useEffect, useMemo, useRef } from "react";
import { ArrowUp, ImagePlus, Square, X } from "lucide-react";
import { cn } from "../lib/utils";
import { Textarea } from "./ui/textarea";
type MessageInputProps = {
handleQueryChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleKeyDown: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
handleQuestionSubmit: () => void;
setSimbaMode: (val: boolean) => void;
simbaMode: boolean;
onToggleSimbaMode: () => void;
query: string;
isLoading: boolean;
onStop: () => void;
pendingImage: File | null;
onImageSelect: (file: File) => void;
onClearImage: () => void;
autoFocus?: boolean;
};
export const MessageInput = React.memo(({
query,
handleKeyDown,
handleQueryChange,
handleQuestionSubmit,
setSimbaMode,
isLoading,
pendingImage,
onImageSelect,
onClearImage,
}: MessageInputProps) => {
const [simbaMode, setLocalSimbaMode] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
export const MessageInput = React.memo(
({
query,
handleKeyDown,
handleQueryChange,
handleQuestionSubmit,
simbaMode,
onToggleSimbaMode,
isLoading,
onStop,
pendingImage,
onImageSelect,
onClearImage,
autoFocus,
}: MessageInputProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Create blob URL once per file, revoke on cleanup
const previewUrl = useMemo(
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
[pendingImage],
);
// Create blob URL once per file, revoke on cleanup
const previewUrl = useMemo(
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
[pendingImage],
);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
// Auto-grow the textarea to fit its content (capped by max-height in CSS).
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [query]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onImageSelect(file);
}
// Reset so the same file can be re-selected
e.target.value = "";
};
}, [previewUrl]);
const toggleSimbaMode = () => {
const next = !simbaMode;
setLocalSimbaMode(next);
setSimbaMode(next);
};
const canSend = !isLoading && Boolean(query.trim() || pendingImage);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onImageSelect(file);
}
// Reset so the same file can be re-selected
e.target.value = "";
};
return (
<div
className={cn(
"rounded-2xl bg-warm-white border border-sand shadow-md shadow-sand/30",
"transition-shadow duration-200 focus-within:shadow-lg focus-within:shadow-amber-soft/20",
"focus-within:border-amber-soft/60",
)}
>
{/* Image preview */}
{pendingImage && (
<div className="px-3 pt-3">
<div className="relative inline-block">
<img
src={previewUrl!}
alt="Pending upload"
className="h-20 rounded-lg object-cover border border-sand"
/>
<button
type="button"
onClick={onClearImage}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-charcoal text-white flex items-center justify-center hover:bg-charcoal/80 transition-colors cursor-pointer"
>
<X size={12} />
</button>
</div>
</div>
)}
const canSend = !isLoading && (query.trim() || pendingImage);
{/* Textarea */}
<Textarea
ref={textareaRef}
onChange={handleQueryChange}
onKeyDown={handleKeyDown}
value={query}
rows={2}
placeholder="Ask Simba anything..."
className="min-h-[60px] max-h-40 overflow-y-auto"
autoFocus={autoFocus}
/>
return (
<div
className={cn(
"rounded-2xl bg-warm-white border border-sand shadow-md shadow-sand/30",
"transition-shadow duration-200 focus-within:shadow-lg focus-within:shadow-amber-soft/20",
"focus-within:border-amber-soft/60",
)}
>
{/* Image preview */}
{pendingImage && (
<div className="px-3 pt-3">
<div className="relative inline-block">
<img
src={previewUrl!}
alt="Pending upload"
className="h-20 rounded-lg object-cover border border-sand"
/>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
{/* Bottom toolbar */}
<div className="flex items-center justify-between px-3 pb-2.5 pt-1">
<div className="flex items-center gap-3">
{/* Simba mode toggle */}
<button
type="button"
onClick={onClearImage}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-charcoal text-white flex items-center justify-center hover:bg-charcoal/80 transition-colors cursor-pointer"
onClick={onToggleSimbaMode}
className="flex items-center gap-2 group cursor-pointer select-none"
>
<X size={12} />
<div className={cn("toggle-track", simbaMode && "checked")}>
<div className="toggle-thumb" />
</div>
<span className="text-xs text-warm-gray group-hover:text-charcoal transition-colors">
simba mode
</span>
</button>
{/* Image attach button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className={cn(
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
isLoading
? "text-warm-gray/40 cursor-not-allowed"
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
<ImagePlus size={16} />
</button>
</div>
</div>
)}
{/* Textarea */}
<Textarea
onChange={handleQueryChange}
onKeyDown={handleKeyDown}
value={query}
rows={2}
placeholder="Ask Simba anything..."
className="min-h-[60px] max-h-40"
/>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
{/* Bottom toolbar */}
<div className="flex items-center justify-between px-3 pb-2.5 pt-1">
<div className="flex items-center gap-3">
{/* Simba mode toggle */}
<button
type="button"
onClick={toggleSimbaMode}
className="flex items-center gap-2 group cursor-pointer select-none"
>
<div className={cn("toggle-track", simbaMode && "checked")}>
<div className="toggle-thumb" />
</div>
<span className="text-xs text-warm-gray group-hover:text-charcoal transition-colors">
simba mode
</span>
</button>
{/* Image attach button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className={cn(
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
isLoading
? "text-warm-gray/40 cursor-not-allowed"
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
<ImagePlus size={16} />
</button>
</div>
{/* Send button */}
<button
type="submit"
onClick={handleQuestionSubmit}
disabled={!canSend}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center",
"transition-all duration-200 cursor-pointer",
"shadow-sm",
!canSend
? "bg-sand text-warm-gray/50 cursor-not-allowed shadow-none"
: "bg-amber-glow text-white hover:bg-amber-dark hover:shadow-md hover:shadow-amber-glow/30 active:scale-95",
{/* Send / Stop button */}
{isLoading ? (
<button
type="button"
onClick={onStop}
aria-label="Stop generating"
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shadow-sm",
"bg-charcoal text-white transition-all duration-200 cursor-pointer",
"hover:bg-charcoal/85 active:scale-95",
)}
>
<Square size={13} strokeWidth={2.5} fill="currentColor" />
</button>
) : (
<button
type="submit"
onClick={handleQuestionSubmit}
disabled={!canSend}
aria-label="Send message"
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center",
"transition-all duration-200 cursor-pointer shadow-sm",
!canSend
? "bg-sand text-warm-gray/50 cursor-not-allowed shadow-none"
: "bg-amber-glow text-white hover:bg-amber-dark hover:shadow-md hover:shadow-amber-glow/30 active:scale-95",
)}
>
<ArrowUp size={15} strokeWidth={2.5} />
</button>
)}
>
<ArrowUp size={15} strokeWidth={2.5} />
</button>
</div>
</div>
</div>
);
});
);
},
);
@@ -0,0 +1,128 @@
import { Link } from "react-router-dom";
import {
Clock,
LogOut,
PanelLeftClose,
Shield,
ListTree,
X,
} from "lucide-react";
import { ConversationList } from "./ConversationList";
import type { Conversation } from "../hooks/useConversations";
import catIcon from "../assets/cat.png";
type SidebarContentProps = {
conversations: Conversation[];
onCreateNewConversation: () => void;
onSelectConversation: (conversation: Conversation) => void;
onRenameConversation: (id: string, title: string) => void;
onDeleteConversation: (id: string) => void;
selectedId?: string;
isAdmin: boolean;
onShowAdmin: () => void;
onShowScheduler: () => void;
onLogout: () => void;
/** Desktop: collapse the sidebar. Renders a PanelLeftClose button. */
onCollapse?: () => void;
/** Mobile: close the drawer. Renders an X button. */
onCloseDrawer?: () => void;
};
/**
* The dark sidebar body shared by the desktop sidebar and the mobile drawer.
* Shows the recent conversations, a link to the full list, and admin/account
* actions. Exactly one of `onCollapse`/`onCloseDrawer` is expected per usage.
*/
export const SidebarContent = ({
conversations,
onCreateNewConversation,
onSelectConversation,
onRenameConversation,
onDeleteConversation,
selectedId,
isAdmin,
onShowAdmin,
onShowScheduler,
onLogout,
onCollapse,
onCloseDrawer,
}: SidebarContentProps) => {
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
<div className="flex items-center gap-2.5">
<img src={catIcon} alt="Simba" className="w-12 h-12" />
<h2
className="text-lg font-bold text-cream tracking-tight"
style={{ fontFamily: "var(--font-display)" }}
>
asksimba
</h2>
</div>
{onCollapse && (
<button
onClick={onCollapse}
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
>
<PanelLeftClose size={15} />
</button>
)}
{onCloseDrawer && (
<button
onClick={onCloseDrawer}
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
>
<X size={16} />
</button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 py-3">
<ConversationList
conversations={conversations}
onCreateNewConversation={onCreateNewConversation}
onSelectConversation={onSelectConversation}
onRenameConversation={onRenameConversation}
onDeleteConversation={onDeleteConversation}
selectedId={selectedId}
/>
</div>
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
<Link
to="/conversations"
onClick={onCloseDrawer}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<ListTree size={14} />
<span>See all conversations</span>
</Link>
{isAdmin && (
<>
<button
onClick={onShowAdmin}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<Shield size={14} />
<span>Admin</span>
</button>
<button
onClick={onShowScheduler}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<Clock size={14} />
<span>Scheduler</span>
</button>
</>
)}
<button
onClick={onLogout}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<LogOut size={14} />
<span>Sign out</span>
</button>
</div>
</div>
);
};
@@ -1,11 +1,14 @@
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
ref?: React.Ref<HTMLTextAreaElement>;
}
export const Textarea = ({ className, ...props }: TextareaProps) => {
export const Textarea = ({ className, ref, ...props }: TextareaProps) => {
return (
<textarea
ref={ref}
className={cn(
"flex w-full resize-none rounded-xl border-0 bg-transparent px-3 py-2.5",
"text-sm text-charcoal placeholder:text-warm-gray/50",
+1 -1
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useState, ReactNode } from "react";
import { createContext, useContext, useState, type ReactNode } from "react";
import { userService } from "../api/userService";
interface AuthContextType {
+140 -22
View File
@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useMatch } from "react-router-dom";
import { conversationService } from "../api/conversationService";
import type { Conversation } from "./useConversations";
@@ -29,7 +30,6 @@ const TOOL_MESSAGES: Record<string, string> = {
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
type UseChatOptions = {
selectedConversation: Conversation | null;
createConversation: () => Promise<Conversation>;
refreshConversations: () => Promise<void>;
onSessionExpired: () => void;
@@ -37,18 +37,30 @@ type UseChatOptions = {
};
export function useChat({
selectedConversation,
createConversation,
refreshConversations,
onSessionExpired,
scrollToBottom,
}: UseChatOptions) {
// ChatScreen renders under a single catch-all route so it isn't remounted
// when the first message navigates to /c/:id (which would abort the stream),
// so the id comes from matching the URL rather than useParams.
const conversationId = useMatch("/c/:conversationId")?.params.conversationId;
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
const [pendingImage, setPendingImage] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [streaming, setStreaming] = useState(false);
const isMountedRef = useRef(true);
// True once tokens for the current assistant reply have started arriving, so
// new content appends to the live bubble instead of creating another one.
const streamingActiveRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null);
// Set to a conversation id we just created locally, so the route-change
// loader below doesn't wipe the optimistic message + in-flight stream.
const skipLoadForIdRef = useRef<string | null>(null);
useEffect(() => {
isMountedRef.current = true;
@@ -66,19 +78,71 @@ export function useChat({
[scrollToBottom],
);
// Load messages whenever the active conversation (URL) changes. Clearing
// immediately makes the switch feel instant instead of lingering on the old
// thread until the fetch resolves.
useEffect(() => {
// Skip only for a conversation we just created locally (its optimistic
// message + stream are already in state). Guard against the null sentinel
// colliding with the "no conversation" (home) route.
if (
skipLoadForIdRef.current !== null &&
skipLoadForIdRef.current === conversationId
) {
skipLoadForIdRef.current = null;
return;
}
if (!conversationId) {
setMessages([]);
setMessagesLoading(false);
return;
}
let cancelled = false;
setMessages([]);
setMessagesLoading(true);
(async () => {
try {
const fetched = await conversationService.getConversation(conversationId);
if (cancelled) return;
setMessages(
(fetched.messages ?? []).map((m) => ({
text: m.text,
speaker: m.speaker,
image_key: m.image_key,
})),
);
scrollToBottom();
} catch (err) {
if (!cancelled) {
console.error("Failed to load messages:", err);
setMessages([]);
}
} finally {
if (!cancelled) setMessagesLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [conversationId, scrollToBottom]);
const sendMessage = useCallback(
async (query: string, simbaMode: boolean) => {
if ((!query.trim() && !pendingImage) || isLoading) return;
let activeConversation = selectedConversation;
let createdNew = false;
if (!activeConversation) {
activeConversation = await createConversation();
createdNew = true;
let activeId = conversationId;
if (!activeId) {
const created = await createConversation();
// Prevent the route-change loader from clearing what we're about to add.
skipLoadForIdRef.current = created.id;
activeId = created.id;
}
const imageFile = pendingImage;
setError(null);
streamingActiveRef.current = false;
setStreaming(false);
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
setPendingImage(null);
setIsLoading(true);
@@ -101,7 +165,7 @@ export function useChat({
if (imageFile) {
const uploadResult = await conversationService.uploadImage(
imageFile,
activeConversation.id,
activeId,
);
imageKey = uploadResult.image_key;
@@ -119,52 +183,94 @@ export function useChat({
await conversationService.streamQuery(
query,
activeConversation.id,
activeId,
(event) => {
if (!isMountedRef.current) return;
if (event.type === "tool_start") {
// Any text after a tool call belongs in a fresh bubble.
streamingActiveRef.current = false;
const friendly =
TOOL_MESSAGES[event.tool] ?? `Using ${event.tool}...`;
updateMessages((prev) =>
prev.concat([{ text: friendly, speaker: "tool" }]),
);
} else if (event.type === "content") {
if (streamingActiveRef.current) {
updateMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
updated[updated.length - 1] = {
...last,
text: last.text + event.delta,
};
return updated;
});
} else {
streamingActiveRef.current = true;
setStreaming(true);
updateMessages((prev) =>
prev.concat([{ text: event.delta, speaker: "simba" }]),
);
}
} else if (event.type === "response") {
updateMessages((prev) =>
prev.concat([{ text: event.message, speaker: "simba" }]),
);
// Reconcile the streamed bubble with the authoritative (persisted)
// text; if nothing streamed, append it as a new message.
if (streamingActiveRef.current) {
updateMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
text: event.message,
};
return updated;
});
streamingActiveRef.current = false;
} else {
updateMessages((prev) =>
prev.concat([{ text: event.message, speaker: "simba" }]),
);
}
} else if (event.type === "error") {
console.error("Stream error:", event.message);
if (isMountedRef.current) {
setError("Simba ran into a problem answering that.");
}
}
},
abortController.signal,
imageKey,
);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.log("Request was aborted");
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// User cancelled — not an error to surface.
} else {
console.error("Failed to send query:", error);
console.error("Failed to send query:", err);
if (
error instanceof Error &&
error.message.includes("Session expired")
err instanceof Error &&
err.message.includes("Session expired")
) {
onSessionExpired();
} else if (isMountedRef.current) {
setError("Couldn't reach Simba. Check your connection and try again.");
}
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
if (createdNew) {
refreshConversations();
}
setStreaming(false);
// Refresh after every send so the just-used conversation floats to
// the top of the sidebar (sorted by last activity) and picks up the
// auto-generated title on the first message.
refreshConversations();
}
streamingActiveRef.current = false;
abortControllerRef.current = null;
}
},
[
pendingImage,
isLoading,
selectedConversation,
conversationId,
createConversation,
refreshConversations,
onSessionExpired,
@@ -172,12 +278,24 @@ export function useChat({
],
);
const stopGeneration = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
}, []);
const clearError = useCallback(() => setError(null), []);
return {
messages,
setMessages: updateMessages,
isLoading,
streaming,
messagesLoading,
pendingImage,
setPendingImage,
sendMessage,
stopGeneration,
error,
clearError,
};
}
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { conversationService } from "../api/conversationService";
import type { Conversation } from "./useConversations";
const PAGE_SIZE = 20;
/**
* Data source for the dedicated /conversations page: server-side search plus
* "Load more" pagination. Detects whether more rows exist by asking for one
* extra item beyond the page size.
*/
export function useConversationBrowser() {
const [items, setItems] = useState<Conversation[]>([]);
const [search, setSearchValue] = useState("");
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
// Guards against a slow earlier request overwriting a newer one.
const requestIdRef = useRef(0);
const fetchPage = useCallback(
async (offset: number, searchTerm: string) => {
const requestId = ++requestIdRef.current;
setLoading(true);
try {
const fetched = await conversationService.getAllConversations({
limit: PAGE_SIZE + 1,
offset,
search: searchTerm || undefined,
});
if (requestId !== requestIdRef.current) return;
const page = fetched
.slice(0, PAGE_SIZE)
.map((c) => ({ id: c.id, title: c.name }));
setHasMore(fetched.length > PAGE_SIZE);
setItems((prev) => (offset === 0 ? page : [...prev, ...page]));
} catch (err) {
if (requestId === requestIdRef.current) {
console.error("Failed to load conversations:", err);
}
} finally {
if (requestId === requestIdRef.current) setLoading(false);
}
},
[],
);
// Debounce search changes; reset to the first page on each new term.
useEffect(() => {
const handle = setTimeout(() => fetchPage(0, search), 250);
return () => clearTimeout(handle);
}, [search, fetchPage]);
const loadMore = useCallback(() => {
if (loading || !hasMore) return;
fetchPage(items.length, search);
}, [loading, hasMore, items.length, search, fetchPage]);
const renameConversation = useCallback(
async (id: string, title: string) => {
const trimmed = title.trim();
if (!trimmed) return;
const previous = items;
setItems((prev) =>
prev.map((c) => (c.id === id ? { ...c, title: trimmed } : c)),
);
try {
await conversationService.renameConversation(id, trimmed);
} catch (err) {
console.error("Failed to rename conversation:", err);
setItems(previous);
}
},
[items],
);
const deleteConversation = useCallback(
async (id: string) => {
const previous = items;
setItems((prev) => prev.filter((c) => c.id !== id));
try {
await conversationService.deleteConversation(id);
} catch (err) {
console.error("Failed to delete conversation:", err);
setItems(previous);
}
},
[items],
);
return {
items,
search,
setSearch: setSearchValue,
hasMore,
loading,
loadMore,
renameConversation,
deleteConversation,
};
}
+63 -31
View File
@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useMatch, useNavigate } from "react-router-dom";
import { conversationService } from "../api/conversationService";
export type Conversation = {
@@ -6,20 +7,22 @@ export type Conversation = {
id: string;
};
type Message = {
text: string;
speaker: "simba" | "user" | "tool";
image_key?: string | null;
};
// The sidebar/drawer only shows the most recent conversations; the full list
// lives on the dedicated /conversations page.
const SIDEBAR_LIMIT = 10;
export function useConversations() {
const navigate = useNavigate();
// ChatScreen now renders under a single catch-all route, so useParams no
// longer captures the id. Derive it from the URL directly instead.
const conversationId = useMatch("/c/:conversationId")?.params.conversationId;
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversation, setSelectedConversation] =
useState<Conversation | null>(null);
const refreshConversations = useCallback(async () => {
try {
const fetched = await conversationService.getAllConversations();
const fetched = await conversationService.getAllConversations({
limit: SIDEBAR_LIMIT,
});
setConversations(fetched.map((c) => ({ id: c.id, title: c.name })));
} catch (err) {
console.error("Failed to load conversations:", err);
@@ -30,40 +33,69 @@ export function useConversations() {
refreshConversations();
}, [refreshConversations]);
const selectConversation = useCallback(
async (conversation: Conversation): Promise<Message[]> => {
setSelectedConversation(conversation);
try {
const fetched = await conversationService.getConversation(
conversation.id,
);
return fetched.messages.map((m) => ({
text: m.text,
speaker: m.speaker,
image_key: m.image_key,
}));
} catch (err) {
console.error("Failed to load messages:", err);
return [];
// The active conversation is derived from the URL. Fall back to a minimal
// object while the list is still loading (e.g. on a deep-link refresh).
const selectedConversation = useMemo<Conversation | null>(() => {
if (!conversationId) return null;
return (
conversations.find((c) => c.id === conversationId) ?? {
id: conversationId,
title: "",
}
},
[],
);
);
}, [conversationId, conversations]);
const createConversation = useCallback(async (): Promise<Conversation> => {
const newConv = await conversationService.createConversation();
const conversation = { title: newConv.name, id: newConv.id };
setConversations((prev) => [conversation, ...prev]);
setSelectedConversation(conversation);
navigate(`/c/${conversation.id}`);
return conversation;
}, []);
}, [navigate]);
const renameConversation = useCallback(
async (id: string, title: string): Promise<void> => {
const trimmed = title.trim();
if (!trimmed) return;
// Optimistic update, roll back on failure. selectedConversation is
// derived from `conversations`, so it updates automatically.
const previous = conversations;
setConversations((prev) =>
prev.map((c) => (c.id === id ? { ...c, title: trimmed } : c)),
);
try {
await conversationService.renameConversation(id, trimmed);
} catch (err) {
console.error("Failed to rename conversation:", err);
setConversations(previous);
}
},
[conversations],
);
const deleteConversation = useCallback(
async (id: string): Promise<void> => {
const previous = conversations;
const wasActive = conversationId === id;
setConversations((prev) => prev.filter((c) => c.id !== id));
// Navigating home clears the message view via the route change.
if (wasActive) navigate("/");
try {
await conversationService.deleteConversation(id);
} catch (err) {
console.error("Failed to delete conversation:", err);
setConversations(previous);
}
},
[conversations, conversationId, navigate],
);
return {
conversations,
selectedConversation,
setSelectedConversation,
selectConversation,
createConversation,
renameConversation,
deleteConversation,
refreshConversations,
};
}
+4 -1
View File
@@ -1,5 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
const rootEl = document.getElementById('root');
@@ -7,7 +8,9 @@ if (rootEl) {
const root = ReactDOM.createRoot(rootEl);
root.render(
<React.StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
}
+210
View File
@@ -598,6 +598,11 @@ comma-separated-tokens@^2.0.0:
resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee"
integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
cookie@^1.0.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
core-js@~3.47.0:
version "3.47.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.47.0.tgz#436ef07650e191afeb84c24481b298bd60eb4a17"
@@ -695,6 +700,11 @@ es-set-tostringtag@^2.1.0:
has-tostringtag "^1.0.2"
hasown "^2.0.2"
escape-string-regexp@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
estree-util-is-identifier-name@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz#0b5ef4c4ff13508b34dcd01ecfa945f61fce5dbd"
@@ -1011,6 +1021,11 @@ magic-string@^0.30.21:
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5"
markdown-table@^3.0.0:
version "3.0.4"
resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a"
integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==
marked@^16.3.0:
version "16.4.2"
resolved "https://registry.yarnpkg.com/marked/-/marked-16.4.2.tgz#4959a64be6c486f0db7467ead7ce288de54290a3"
@@ -1021,6 +1036,16 @@ math-intrinsics@^1.1.0:
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
mdast-util-find-and-replace@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz#70a3174c894e14df722abf43bc250cbae44b11df"
integrity sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==
dependencies:
"@types/mdast" "^4.0.0"
escape-string-regexp "^5.0.0"
unist-util-is "^6.0.0"
unist-util-visit-parents "^6.0.0"
mdast-util-from-markdown@^2.0.0:
version "2.0.3"
resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz#c95822b91aab75f18a4cbe8b2f51b873ed2cf0c7"
@@ -1039,6 +1064,71 @@ mdast-util-from-markdown@^2.0.0:
micromark-util-types "^2.0.0"
unist-util-stringify-position "^4.0.0"
mdast-util-gfm-autolink-literal@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz#abd557630337bd30a6d5a4bd8252e1c2dc0875d5"
integrity sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==
dependencies:
"@types/mdast" "^4.0.0"
ccount "^2.0.0"
devlop "^1.0.0"
mdast-util-find-and-replace "^3.0.0"
micromark-util-character "^2.0.0"
mdast-util-gfm-footnote@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz#7778e9d9ca3df7238cc2bd3fa2b1bf6a65b19403"
integrity sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==
dependencies:
"@types/mdast" "^4.0.0"
devlop "^1.1.0"
mdast-util-from-markdown "^2.0.0"
mdast-util-to-markdown "^2.0.0"
micromark-util-normalize-identifier "^2.0.0"
mdast-util-gfm-strikethrough@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz#d44ef9e8ed283ac8c1165ab0d0dfd058c2764c16"
integrity sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==
dependencies:
"@types/mdast" "^4.0.0"
mdast-util-from-markdown "^2.0.0"
mdast-util-to-markdown "^2.0.0"
mdast-util-gfm-table@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz#7a435fb6223a72b0862b33afbd712b6dae878d38"
integrity sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==
dependencies:
"@types/mdast" "^4.0.0"
devlop "^1.0.0"
markdown-table "^3.0.0"
mdast-util-from-markdown "^2.0.0"
mdast-util-to-markdown "^2.0.0"
mdast-util-gfm-task-list-item@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz#e68095d2f8a4303ef24094ab642e1047b991a936"
integrity sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==
dependencies:
"@types/mdast" "^4.0.0"
devlop "^1.0.0"
mdast-util-from-markdown "^2.0.0"
mdast-util-to-markdown "^2.0.0"
mdast-util-gfm@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz#2cdf63b92c2a331406b0fb0db4c077c1b0331751"
integrity sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==
dependencies:
mdast-util-from-markdown "^2.0.0"
mdast-util-gfm-autolink-literal "^2.0.0"
mdast-util-gfm-footnote "^2.0.0"
mdast-util-gfm-strikethrough "^2.0.0"
mdast-util-gfm-table "^2.0.0"
mdast-util-gfm-task-list-item "^2.0.0"
mdast-util-to-markdown "^2.0.0"
mdast-util-mdx-expression@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz#43f0abac9adc756e2086f63822a38c8d3c3a5096"
@@ -1153,6 +1243,85 @@ micromark-core-commonmark@^2.0.0:
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-extension-gfm-autolink-literal@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz#6286aee9686c4462c1e3552a9d505feddceeb935"
integrity sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==
dependencies:
micromark-util-character "^2.0.0"
micromark-util-sanitize-uri "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-extension-gfm-footnote@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz#4dab56d4e398b9853f6fe4efac4fc9361f3e0750"
integrity sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==
dependencies:
devlop "^1.0.0"
micromark-core-commonmark "^2.0.0"
micromark-factory-space "^2.0.0"
micromark-util-character "^2.0.0"
micromark-util-normalize-identifier "^2.0.0"
micromark-util-sanitize-uri "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-extension-gfm-strikethrough@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz#86106df8b3a692b5f6a92280d3879be6be46d923"
integrity sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==
dependencies:
devlop "^1.0.0"
micromark-util-chunked "^2.0.0"
micromark-util-classify-character "^2.0.0"
micromark-util-resolve-all "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-extension-gfm-table@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz#fac70bcbf51fe65f5f44033118d39be8a9b5940b"
integrity sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==
dependencies:
devlop "^1.0.0"
micromark-factory-space "^2.0.0"
micromark-util-character "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-extension-gfm-tagfilter@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz#f26d8a7807b5985fba13cf61465b58ca5ff7dc57"
integrity sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==
dependencies:
micromark-util-types "^2.0.0"
micromark-extension-gfm-task-list-item@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz#bcc34d805639829990ec175c3eea12bb5b781f2c"
integrity sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==
dependencies:
devlop "^1.0.0"
micromark-factory-space "^2.0.0"
micromark-util-character "^2.0.0"
micromark-util-symbol "^2.0.0"
micromark-util-types "^2.0.0"
micromark-extension-gfm@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz#3e13376ab95dd7a5cfd0e29560dfe999657b3c5b"
integrity sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==
dependencies:
micromark-extension-gfm-autolink-literal "^2.0.0"
micromark-extension-gfm-footnote "^2.0.0"
micromark-extension-gfm-strikethrough "^2.0.0"
micromark-extension-gfm-table "^2.0.0"
micromark-extension-gfm-tagfilter "^2.0.0"
micromark-extension-gfm-task-list-item "^2.0.0"
micromark-util-combine-extensions "^2.0.0"
micromark-util-types "^2.0.0"
micromark-factory-destination@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639"
@@ -1464,6 +1633,21 @@ react-refresh@^0.18.0:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062"
integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==
react-router-dom@^7.18.3:
version "7.18.3"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.18.3.tgz#0688c91b4b376c8b0dcaf0a0064975d52a32e0f7"
integrity sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==
dependencies:
react-router "7.18.3"
react-router@7.18.3:
version "7.18.3"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.3.tgz#2a3257aa7c5edd5a71f878063e4c7f3fcfc4b76a"
integrity sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
react@^19.1.1:
version "19.2.4"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a"
@@ -1485,6 +1669,18 @@ readdirp@~3.6.0:
dependencies:
picomatch "^2.2.1"
remark-gfm@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b"
integrity sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==
dependencies:
"@types/mdast" "^4.0.0"
mdast-util-gfm "^3.0.0"
micromark-extension-gfm "^3.0.0"
remark-parse "^11.0.0"
remark-stringify "^11.0.0"
unified "^11.0.0"
remark-parse@^11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1"
@@ -1506,6 +1702,15 @@ remark-rehype@^11.0.0:
unified "^11.0.0"
vfile "^6.0.0"
remark-stringify@^11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-11.0.0.tgz#4c5b01dd711c269df1aaae11743eb7e2e7636fd3"
integrity sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==
dependencies:
"@types/mdast" "^4.0.0"
mdast-util-to-markdown "^2.0.0"
unified "^11.0.0"
safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
@@ -1521,6 +1726,11 @@ semver@^7.5.3:
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
set-cookie-parser@^2.6.0:
version "2.7.2"
resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68"
integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==
simple-update-notifier@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb"
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# Zero-downtime blue-green deploy.
#
# Builds the inactive color, starts it, waits for its healthcheck to pass,
# then gracefully stops the old color. Caddy health-checks both ports
# (8080 = blue, 8081 = green) and routes traffic to whichever is healthy,
# so the swap is invisible to clients.
#
# Caveats:
# - Migrations run on the new container's startup while the old one still
# serves, so they must be backward-compatible (additive changes only;
# destructive changes need a two-step deploy).
# - During the brief overlap both containers run pull-only Obsidian sync
# against the shared ./obvault mount.
set -euo pipefail
cd "$(dirname "$0")/.."
running_services=$(docker compose ps --status running --services)
if echo "$running_services" | grep -qx "raggr-blue"; then
OLD=blue NEW=green
elif echo "$running_services" | grep -qx "raggr-green"; then
OLD=green NEW=blue
else
OLD="" NEW=blue
fi
echo "==> Active: ${OLD:-none} | Deploying: $NEW"
echo "==> Building raggr-$NEW..."
docker compose --profile "$NEW" build "raggr-$NEW"
echo "==> Starting raggr-$NEW and waiting for healthy..."
if ! docker compose --profile "$NEW" up -d --wait "raggr-$NEW"; then
echo "!! raggr-$NEW failed to become healthy${OLD:+; raggr-$OLD is still serving}" >&2
docker compose --profile "$NEW" logs --tail 50 "raggr-$NEW" >&2 || true
docker compose --profile "$NEW" stop -t 5 "raggr-$NEW" || true
exit 1
fi
if [ -n "$OLD" ]; then
echo "==> raggr-$NEW healthy; stopping raggr-$OLD (30s grace for in-flight streams)..."
docker compose --profile "$OLD" stop -t 30 "raggr-$OLD"
docker compose --profile "$OLD" rm -f "raggr-$OLD"
fi
echo "==> Deploy complete: raggr-$NEW is live"