Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bddd741fcc | |||
| 2db7e6f2e5 | |||
| b23fd19c4b | |||
| 4e3842495f | |||
| 434c394651 | |||
| 20d44bb3b3 | |||
| d8d01a5fff | |||
| 3318839551 | |||
| 4fe5b8daea | |||
| 53f76f58ea | |||
| b918522816 | |||
| eb8cd19e46 | |||
| 09dd983c4e | |||
| 35a2e10bc8 | |||
| 530a90f237 | |||
| bde4a994b7 | |||
| 6a62db2e85 | |||
| 47db945393 | |||
| 6293d9df0a | |||
| 32285891fc | |||
| 7aef6484c3 |
@@ -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/`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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():
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+48
@@ -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"
|
||||
Reference in New Issue
Block a user