diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1aa7665 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# duh environment variables +# Copy to .env and fill in values. Never commit .env to git. + +# JWT secret for auth tokens (required in production; auto-generated in dev if empty) +DUH_JWT_SECRET= + +# Mail (SMTP) configuration for password reset emails +DUH_MAIL_HOST=localhost +DUH_MAIL_PORT=1025 +DUH_MAIL_USERNAME= +DUH_MAIL_PASSWORD= +DUH_MAIL_ENCRYPTION= +DUH_MAIL_FROM_ADDRESS=noreply@example.com +DUH_MAIL_FROM_NAME=duh diff --git a/.gitignore b/.gitignore index 4be3820..ca4bd90 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ ENV/ # Environment .env .env.* +!.env.example # Phase 0 results results/ diff --git a/AGENTS.md b/AGENTS.md index 048a405..1d14dde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -**Version**: 2.1 (2025-10-25) | **Compatibility**: Claude, Cursor, Copilot, Cline, Aider, all AGENTS.md-compatible tools +**Version**: 2.2 (2025-03-04) | **Compatibility**: Claude, Cursor, Copilot, Cline, Aider, all AGENTS.md-compatible tools **Status**: Canonical single-file guide for AI-assisted development --- @@ -9,6 +9,7 @@ 1. [Compliance & Core Rules](#1-compliance--core-rules) 2. [Session Startup](#2-session-startup) + - [Compaction Protocol](#compaction-protocol-mid-session-context-preservation) 3. [Memory Bank](#3-memory-bank) 4. [State Machine](#4-state-machine) 5. [Task Contract & Budgets](#5-task-contract--budgets) @@ -106,6 +107,45 @@ Append-only JSONL format: {"timestamp":"2025-10-25T11:00:00Z","session_id":"uuid","event":"approval_requested","state":"APPROVAL"} ``` +### Compaction Protocol (Mid-Session Context Preservation) + +Compaction (context compression) can happen at any time — triggered by the system automatically, by the user via `/compact`, or by platform-level context management. **The agent does not control compaction timing and may not get advance notice.** Therefore, state persistence must be continuous, not deferred to a pre-compaction moment. + +#### Continuous State Persistence (At Every State Transition) + +At each state transition (`PLAN → BUILD → DIFF → QA → APPROVAL → APPLY → DOCS`), persist the following to the Memory Bank: + +1. **State machine position**: Update `activeContext.md` with current state, substate, and working context +2. **Task progress**: Append current status to `tasks/YYYY-MM/README.md` with `[IN-PROGRESS]` tag +3. **Decisions**: Append any new architectural decisions to `decisions.md` +4. **Log transition** to operational log: + ```json + {"timestamp":"...","session_id":"uuid","event":"state_transition","from":"PLAN","to":"BUILD"} + ``` +5. **Loose context**: Capture any information that exists only in conversation (user preferences, verbal requirements, pending questions) into `activeContext.md` + +This ensures that when compaction occurs — without warning — the Memory Bank already reflects the latest state. + +#### After Compaction (Recovery) + +When context has been compressed (detected by loss of earlier conversation detail, or after `/compact`): + +1. Re-enter **Session Startup** (Section 2) using **Fast Track** mode — the Memory Bank was just updated via continuous persistence, so full discovery is unnecessary +2. Confirm state machine position from `activeContext.md` +3. Resume from saved state — do not restart the current task from scratch +4. Output recovery confirmation: + ``` + COMPACTION RECOVERY: Resumed [STATE] for [task name] + Context restored from: activeContext.md, tasks/YYYY-MM/README.md + ``` + +#### Rules + +- State persistence happens at every transition, not "before compaction" — you cannot rely on advance notice +- After detecting compaction, always re-read Memory Bank before taking any action +- If the current state is `APPROVAL` or `DIFF`, the diff summary should already be in `activeContext.md` from the transition save +- Compaction does not reset budgets — carry forward cycle/token/minute counts from the operational log + --- ## 3. Memory Bank @@ -577,7 +617,7 @@ Create outline for approval. After approval, do work. Do not document until I ap 2. **Task** (current task): Files being modified, direct dependencies, related tests 3. **Reference** (on-demand): Arch patterns, similar implementations, historical decisions -**Context Rotation**: After each state transition, drop Task Context, reload only what's needed for next state. Keep Core Context persistent. +**Context Rotation**: After each state transition, drop Task Context, reload only what's needed for next state. Keep Core Context persistent. State is persisted to Memory Bank at every transition per **Compaction Protocol** (Section 2), so compaction recovery is automatic. **Parallel Execution**: ``` @@ -896,7 +936,7 @@ Stuck? → Cycles ≥3? | Issue | Symptoms | Resolution | |-------|----------|------------| | **Loop** | Same diff multiple times, QA fails repeatedly, no progress after 3+ cycles | Check budgets → Load more MB → Clarify requirements → Check environment → Agent swap | -| **Context Exceeded** | Token limit approaching, slow/truncated responses, forgetting earlier info | Rotate context (drop Task, reload essentials) → Focused mode (MB summaries only) → Break into subtasks → Agent swap | +| **Context Exceeded** | Token limit approaching, slow/truncated responses, forgetting earlier info | State already persisted via **Compaction Protocol** (Section 2) → Rotate context (drop Task, reload essentials) → Focused mode (MB summaries only) → Break into subtasks → Agent swap | | **CI ≠ Local** | QA passes, CI fails | Compare environments → Verify dependency versions → Check timing/concurrency → Check state cleanup → Document waiver if CI issue | | **Security Fail** | Security checklist incomplete, sensitive data exposed, auth/authz bypassed | Never bypass → Return to BUILD → Fix all issues → Re-test → Document pattern if new | diff --git a/README.md b/README.md index 08a0649..3ae4bf2 100644 --- a/README.md +++ b/README.md @@ -21,19 +21,22 @@ duh ask "What database should I use for a new SaaS product?" ## Features -- **Multi-model consensus** -- Claude, GPT, Gemini, and Mistral debate. Sycophantic challenges are detected and flagged. +- **Multi-model consensus** -- Claude, GPT, Gemini, Mistral, and Perplexity debate. Sycophantic challenges are detected and flagged. +- **Web UI** -- Real-time consensus streaming, thread browser, 3D decision space, calibration dashboard. `duh serve` serves both API and frontend. +- **Epistemic confidence** -- Rigor scoring + domain-capped confidence. Calibration analysis with ECE tracking. +- **Authentication** -- JWT auth with user accounts, RBAC (admin/contributor/viewer), password reset via email. - **Voting protocol** -- Fan out to all models in parallel, aggregate answers via majority or weighted synthesis. - **Query decomposition** -- Break complex questions into subtask DAGs, solve in parallel, synthesize results. -- **REST API** -- Full HTTP API via `duh serve` with API key auth, rate limiting, and WebSocket streaming. +- **REST API** -- Full HTTP API with API key auth, rate limiting, WebSocket streaming, and Prometheus metrics. - **MCP server** -- AI agent integration via `duh mcp` (Model Context Protocol). - **Python client** -- Async and sync client library for the REST API (`pip install duh-client`). - **Batch processing** -- Process multiple questions from a file (`duh batch`). -- **Export** -- Export threads as JSON or Markdown (`duh export`). -- **Mistral provider** -- Native Mistral AI support alongside Anthropic, OpenAI, and Google. +- **Export** -- Export threads as JSON, Markdown, or PDF (`duh export`). - **Decision taxonomy** -- Auto-classify decisions by intent, category, and genus for structured recall. - **Outcome tracking** -- Record success/failure/partial feedback on past decisions. - **Tool-augmented reasoning** -- Models can call web search, read files, and execute code during consensus. -- **Persistent memory** -- Every thread, contribution, decision, vote, and subtask stored in SQLite. Search with `duh recall`. +- **Persistent memory** -- SQLite or PostgreSQL. Every thread, contribution, decision, vote, and subtask stored. Search with `duh recall`. +- **Backup & restore** -- `duh backup` / `duh restore` with merge mode for SQLite and JSON export. - **Cost tracking** -- Per-model token costs in real-time. Configurable warn threshold and hard limit. - **Local models** -- Ollama and LM Studio via the OpenAI-compatible API. Mix cloud + local. - **Rich CLI** -- Styled panels, spinners, and formatted output. @@ -59,6 +62,12 @@ duh batch questions.txt # Process multiple questions duh batch questions.jsonl --format json # Batch with JSON output duh export # Export thread as JSON duh export --format markdown # Export as Markdown +duh export --format pdf # Export as PDF +duh backup ./backup.db # Backup database +duh restore ./backup.db # Restore database +duh calibration # Show confidence calibration +duh user-create --email u@x.com --password ... # Create user +duh user-list # List users ``` ## How consensus works @@ -109,8 +118,13 @@ Full documentation: [docs/](docs/index.md) - [Export](docs/export.md) - [Python API](docs/python-api/library-usage.md) - [Docker Guide](docs/guides/docker.md) +- [Authentication](docs/guides/authentication.md) - [Config Reference](docs/reference/config-reference.md) +## Sponsor + +If duh is useful to you, consider [sponsoring the project](https://github.com/sponsors/msitarzewski). + ## License TBD diff --git a/docs/concepts/epistemic-confidence.md b/docs/concepts/epistemic-confidence.md new file mode 100644 index 0000000..b1ac8ff --- /dev/null +++ b/docs/concepts/epistemic-confidence.md @@ -0,0 +1,99 @@ +# Epistemic Confidence + +duh uses a two-dimensional confidence system that separates the quality of the deliberation process (**rigor**) from the theoretical limits of the question domain (**domain cap**). The final **confidence** score reflects both. + +## Key concepts + +### Rigor + +Rigor measures how well the consensus process challenged the answer. It ranges from 0.5 to 1.0 and is computed during the COMMIT phase based on: + +- How substantive the challenges were +- Whether the revision addressed the challenges +- Whether multiple rounds of deliberation improved the answer + +A high rigor score means the answer survived meaningful scrutiny. A low rigor score means the challenges were weak or the proposer didn't adequately respond. + +### Domain caps + +Not all questions can be answered with equal certainty. duh classifies each question's **intent** (via taxonomy) and applies a ceiling: + +| Domain | Cap | Rationale | +|--------|-----|-----------| +| Factual | 0.95 | Verifiable facts can be highly certain | +| Technical | 0.90 | Technical analysis has some inherent uncertainty | +| Creative | 0.85 | Creative work is subjective | +| Judgment | 0.80 | Value judgments vary by perspective | +| Strategic | 0.70 | Strategy involves unpredictable futures | +| Default | 0.85 | When classification is uncertain | + +### Confidence + +The final confidence score is: + +``` +confidence = min(domain_cap(intent), rigor) +``` + +This means even a perfect deliberation process can't claim 95% confidence on a strategic question -- the domain cap limits it to 70%. + +### Intent classification + +During the COMMIT phase, duh classifies the question's intent using a taxonomy. The classification determines which domain cap applies. Examples: + +- "What year was Python released?" -- factual (cap: 0.95) +- "Should I use PostgreSQL or MongoDB?" -- technical (cap: 0.90) +- "Write a poem about the ocean" -- creative (cap: 0.85) +- "Is remote work better than office work?" -- judgment (cap: 0.80) +- "Should we expand into the European market?" -- strategic (cap: 0.70) + +## Calibration + +Calibration measures whether confidence scores are accurate over time. A well-calibrated system means: + +- Decisions with 90% confidence should be correct ~90% of the time +- Decisions with 70% confidence should be correct ~70% of the time + +### Recording outcomes + +To build calibration data, record whether decisions were correct: + +**CLI**: +```bash +duh feedback success # Decision was correct +duh feedback failure # Decision was wrong +duh feedback partial # Decision was partially correct +``` + +**Web UI**: Use the inline Pass/Partial/Fail buttons on the Threads page (/threads). + +**API**: +```bash +curl -X POST http://localhost:8080/api/feedback \ + -H "Content-Type: application/json" \ + -d '{"thread_id": "abc123", "result": "success"}' +``` + +### ECE (Expected Calibration Error) + +The calibration page shows ECE -- a single number that summarizes how well-calibrated the system is. Lower is better: + +- **ECE < 0.05**: Excellent calibration +- **ECE 0.05--0.10**: Good calibration +- **ECE > 0.10**: Needs more data or model adjustment + +ECE is computed by: +1. Bucketing decisions by confidence (e.g., 0.7--0.8) +2. Comparing mean predicted confidence to actual success rate in each bucket +3. Averaging the absolute difference, weighted by bucket size + +### Viewing calibration + +- **CLI**: `duh calibration` shows a table of calibration buckets +- **Web UI**: Visit `/calibration` for a visual calibration curve +- **API**: `GET /api/calibration` returns bucket data + +## Related + +- [How Consensus Works](how-consensus-works.md) -- The deliberation process that produces rigor scores +- [Web UI](../web-ui.md) -- Calibration page and batch feedback diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md index 35b8321..9518502 100644 --- a/docs/guides/authentication.md +++ b/docs/guides/authentication.md @@ -282,6 +282,35 @@ X-RateLimit-Remaining: 57 X-RateLimit-Key: user:a1b2c3d4-... ``` +## Web UI Authentication + +The web UI integrates with the backend authentication system. It detects whether auth is required and adapts accordingly. + +### Dev mode (no auth required) + +When no API keys or users exist in the database, the API runs in open mode. The web UI detects this via `GET /api/auth/status` and automatically logs in as a guest user. No login page is shown. + +This is the default behavior when you first run `duh serve` -- you can start using the web UI immediately without setting up users. + +### Production mode (auth required) + +Once you create a user or API key, the web UI requires authentication: + +1. **Redirect to login**: All routes except `/share/:id` redirect to `/login` if the user is not authenticated +2. **Login form**: Enter email and password to receive a JWT token +3. **Token storage**: The JWT token is stored in `localStorage` (key: `duh_token`) +4. **Auto-injection**: The API client automatically includes the token in all requests via the `Authorization: Bearer` header +5. **WebSocket auth**: The token is included in the initial WebSocket handshake message +6. **Session expiry**: On 401 responses, the stored token is cleared and the user is redirected to login + +### User menu + +When authenticated, the top bar shows the user's display name and role badge. Clicking it reveals a dropdown with the user's email and a sign-out button. + +### Registration + +The login page includes a toggle to switch between "Sign In" and "Create Account" modes. Registration can be disabled server-side by setting `registration_enabled = false` in `config.toml`. + ## Security recommendations 1. **Generate a strong JWT secret**: `openssl rand -hex 32` diff --git a/docs/web-ui.md b/docs/web-ui.md index fa6074e..0b3c709 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -44,6 +44,7 @@ A live cost ticker shows cumulative spend during streaming. When consensus compl Browse and search all past consensus sessions. - **Thread list** -- Shows question, status, and creation date for each thread +- **Batch feedback** -- Completed threads show inline Pass/Partial/Fail buttons for quick outcome recording. This feeds the calibration system without having to open each thread individually. - **Search** -- Filter threads by keyword - **Status filter** -- Filter by `active`, `complete`, or `failed` - **Thread detail** (/threads/:id) -- Drill into a specific thread to see the full debate history: every round's proposal, challenges, revision, and decision @@ -75,6 +76,25 @@ Persistent UI settings stored in `localStorage`: | Cost threshold | USD limit before warning | | Sound effects | Toggle phase transition sounds | +### Calibration (/calibration) + +Confidence calibration analysis. Shows how well duh's confidence scores predict actual outcomes. + +- **Calibration curve**: Buckets of predicted confidence vs. actual success rate +- **ECE (Expected Calibration Error)**: How well-calibrated the predictions are (lower is better) +- **Category filter**: Break down calibration by decision category (factual, technical, creative, etc.) + +The page requires outcome data to be useful. Use the batch feedback buttons on the Threads page or `duh feedback` CLI to record whether decisions were correct. + +### Login (/login) + +The login page appears when authentication is required (production mode). Features: + +- Toggle between "Sign In" and "Create Account" modes +- Email and password form using the glassmorphism design system +- Inline error messages for failed authentication +- Automatic redirect to `/` on successful login + ### Share (/share/:id) A standalone page (no sidebar) for viewing shared consensus results via a public share token. Accessible without authentication. @@ -117,12 +137,14 @@ The frontend uses a typed API client at `web/src/api/client.ts` that wraps `fetc The WebSocket client (`web/src/api/websocket.ts`) handles consensus streaming. It auto-detects `ws:` vs `wss:` based on the page protocol and connects to `/ws/ask` on the current host. -State management uses four Zustand stores: +State management uses six Zustand stores: | Store | Purpose | |-------|---------| +| `auth` | JWT token, user info, login/logout, dev mode detection | | `consensus` | WebSocket connection, phase tracking, round data, final result | -| `threads` | Thread list, search, pagination | +| `threads` | Thread list, search, pagination, batch feedback | +| `calibration` | Calibration buckets, ECE, accuracy metrics | | `decision-space` | Decision data, filters, timeline position | | `preferences` | User settings (persisted to localStorage) | diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 2790c0e..bb1aae2 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -1,39 +1,54 @@ # Active Context -**Last Updated**: 2026-02-19 -**Current Phase**: UX cleanup and consensus engine hardening -**Next Action**: PR ready for review. +**Last Updated**: 2026-02-20 +**Current Phase**: v0.6.0 "It's Honest" — sign-out bug fix in progress +**Next Action**: User needs to rebuild (`cd web && npm run build`) and test sign-out. If it works, PR ready. -## What Just Shipped: UX Cleanup + Consensus Engine Improvements +## v0.6.0 "It's Honest" — Complete (minus sign-out bug) -### Thread Detail UX -- All round sections collapsed by default when thread loads — decision stays open -- Dissent inside decision block collapsed by default -- `DissentBanner` gained `defaultOpen` prop for caller control +All 9 tasks (T1-T9) implemented: +- T1: Auth store (Zustand) — `web/src/stores/auth.ts` +- T2: API client auth integration — Bearer token injection, 401 handling, WS token handshake +- T3: Login page — `web/src/pages/LoginPage.tsx` +- T4: Route protection — `web/src/components/shared/ProtectedRoute.tsx`, TopBar user menu +- T5: Dev mode detection — `GET /api/auth/status` endpoint, guest fallback +- T6: Batch feedback — inline Pass/Partial/Fail buttons on ThreadCard +- T7: Frontend tests — 11 auth store + 8 auth component tests +- T8: Documentation — web-ui auth, authentication guide, epistemic-confidence concept doc +- T9: Version bump to 0.6.0 -### Consensus Engine Hardening -- **max_tokens bumped 4096 -> 16384** for propose/challenge/revise phases — prevents LLM output truncation on long responses -- **Token budget in system prompts** — LLMs now told their output budget so they can self-regulate length and end on complete thoughts -- **Truncation detection** — `finish_reason` checked after each handler call; `truncated` flag sent via WebSocket; amber warning shown in PhaseCard UI -- **Cross-provider challenger selection** — `select_challengers()` now prefers models from different providers (one per provider first, then fills). Prevents e.g. Opus proposing + two Sonnet variants challenging (same training biases) +### Sign-Out Bug (IN PROGRESS) -### Visual Polish -- Export dropdown menus (both `ConsensusComplete` and `ExportMenu`) now use glass styling matching the design system (`glass-bg` + `backdrop-blur`) +**Problem**: Clicking "Sign Out" in TopBar user menu dropdown does nothing — menu closes but user stays authenticated. -### PDF Export Bug Fix -- `_setup_fonts()` was missing the bold-italic (`BI`) TTF font variant — caused crash when dissent content contained bold markdown rendered in italic context +**Root cause**: The outside-click handler used `document.addEventListener('mousedown', ...)` which was intercepting ALL mouse events inside the dropdown (including on the Sign Out button), closing the menu before the click handler could fire. User confirmed: "I can't right click to inspect — the interface disappears." + +**Fix applied** (`web/src/components/layout/TopBar.tsx`): +- Removed the broken `mousedown` document listener entirely +- Replaced with invisible backdrop pattern (`fixed inset-0 z-40` div behind dropdown) +- Dropdown at `z-50` — clicks on menu items hit menu, clicks elsewhere hit backdrop +- Sign Out uses plain `onClick` → `logout()` + `window.location.href = '/login'` (hard redirect) +- Removed `useNavigate` dependency — hard redirect avoids React lifecycle race conditions +- Removed `useRef` for menuRef — no longer needed + +**Status**: Code written and built (`npm run build` ran successfully). User needs to restart server or hard-refresh (Cmd+Shift+R) to test. Previous attempts failed because browser was serving cached old JS bundle. + +**If sign-out still fails after rebuild**: The `handleLogout` function is simple (`logout()` clears localStorage + Zustand, then `window.location.href` does hard redirect). If it still doesn't work, add `console.log('handleLogout called')` at the top of the function to verify it fires. + +### Other fix applied this session +- Auto-generated JWT secret in `src/duh/config/loader.py:141-149` — generates `secrets.token_hex(32)` when no JWT secret configured, checks `DUH_JWT_SECRET` env var first. Note: tokens won't survive server restarts with auto-generated secret. ### Test Results -- 1586 Python tests + 166 Vitest tests (1752 total) +- 1586 Python tests + 185 Vitest tests (1771 total) - Build clean, all tests pass --- ## Current State -- **Branch `ux-cleanup`** — ready for PR. -- **1586 Python tests + 166 Vitest tests** (1752 total). -- All previous features intact (v0.1–v0.5 + export + epistemic confidence + consensus nav). +- **Branch `ux-cleanup`** — v0.6.0 features complete, sign-out fix pending user verification +- **1586 Python tests + 185 Vitest tests** (1771 total) +- All previous features intact (v0.1–v0.5 + export + epistemic confidence + consensus nav) ## Open Questions (Still Unresolved) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 2dd8c67..2350936 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -1,10 +1,10 @@ # Progress -**Last Updated**: 2026-02-18 +**Last Updated**: 2026-02-19 --- -## Current State: Consensus Nav + Collapsible Sections COMPLETE +## Current State: v0.6.0 — "It's Honest" COMPLETE ### Consensus Navigation & Collapsible Sections @@ -182,3 +182,11 @@ Phase 0 benchmark framework — fully functional, pilot-tested on 5 questions. | 2026-02-18 | Epistemic Confidence Phase A (rigor + domain caps + calibration) | Done | | 2026-02-18 | Consensus nav + collapsible sections + decision-first layout | Done | | 2026-02-19 | UX cleanup: collapse defaults, max_tokens 16384, cross-provider challengers, truncation detection, glass exports, PDF BI font fix | Done | +| 2026-02-19 | v0.6 T1-T5: Frontend auth (auth store, API client auth, login page, route protection, dev mode) | Done | +| 2026-02-19 | v0.6 T6: Batch feedback on threads list (inline Pass/Partial/Fail buttons, backend outcome enrichment) | Done | +| 2026-02-19 | v0.6 T7: Frontend auth + feedback tests (11 auth store + 8 auth component tests) | Done | +| 2026-02-19 | v0.6 T8: Documentation (web-ui auth, authentication guide, epistemic-confidence concept doc) | Done | +| 2026-02-19 | v0.6 T9: Version bump to 0.6.0 | Done | +| 2026-02-19 | v0.6.0 — "It's Honest" | **Complete** | +| 2026-02-20 | Fix sign-out bug: replaced broken mousedown outside-click handler with backdrop pattern in TopBar | Pending verification | +| 2026-02-20 | Auto-generate JWT secret in config loader for dev environments | Done | diff --git a/memory-bank/roadmap.md b/memory-bank/roadmap.md index 6db0a06..7a0296b 100644 --- a/memory-bank/roadmap.md +++ b/memory-bank/roadmap.md @@ -512,6 +512,35 @@ All → T15-T18 (Ship) --- +### v0.6.0 — "It's Honest" + +**AI-time**: 1 session +**Theme**: Close the gap between what's claimed and what actually works. + +#### What Ships + +- **Frontend auth integration**: Auth Zustand store, login/register page, route protection, user menu, dev mode detection +- **API client auth**: Bearer token injection in all REST and WebSocket requests, 401 handling +- **Batch feedback**: Inline Pass/Partial/Fail buttons on threads list for calibration data collection +- **Epistemic confidence docs**: Rigor, domain caps, calibration, ECE explained +- **Auth status endpoint**: `GET /api/auth/status` for frontend dev mode detection + +#### Tasks (9 tasks) + +1. Auth store (Zustand): token, user, login/register/logout, localStorage persistence +2. API client auth: Bearer header injection, 401 clearing, auth API methods +3. Login/Register page: glassmorphism form, mode toggle, error display +4. Route protection: ProtectedRoute component, Shell user menu +5. Dev mode: detect via /api/auth/status, guest access when no auth required +6. Batch feedback: ThreadCard inline buttons, backend outcome enrichment +7. Tests: 11 auth store tests + 8 auth component tests +8. Documentation: web-ui.md auth section, authentication.md web UI section, epistemic-confidence.md +9. Version bump: 0.6.0 across pyproject.toml, __init__.py, app.py, Sidebar + +> **v0.6.0 shipped 2026-02-19.** 1574 Python tests + 185 Vitest tests (1759 total). Frontend auth integration end-to-end. Batch feedback on threads list. Epistemic confidence documentation. All 9 tasks delivered. + +--- + ### v1.0.0 — "duh." **AI-time**: 5-8 days @@ -911,6 +940,7 @@ Y = yes, N = no, ~ = partial, * = research only, not product | 2026-02-16 | 1.3 | v0.2.0 complete. All features shipped. Updated acceptance criteria and task status. Added Status column to overview table. | | 2026-02-17 | 1.4 | v0.3.0 and v0.4.0 complete. All features shipped. Updated acceptance criteria, task status, and completion notes for both versions. | | 2026-02-17 | 1.5 | v0.5.0 complete. All 18 tasks shipped. 6 providers, multi-user auth, PostgreSQL, metrics, backup/restore, Playwright E2E, load tests, production docs. | +| 2026-02-19 | 1.6 | v0.6.0 complete. Frontend auth integration, batch feedback, epistemic confidence docs. Bridges the gap between v0.5 (backend auth) and v1.0. | --- diff --git a/memory-bank/tasks/2026-03/070307_password-reset.md b/memory-bank/tasks/2026-03/070307_password-reset.md new file mode 100644 index 0000000..69d9bba --- /dev/null +++ b/memory-bank/tasks/2026-03/070307_password-reset.md @@ -0,0 +1,47 @@ +# 070307_password-reset + +## Objective +Add password reset flow with email delivery, .env configuration support, and frontend UI. + +## Outcome +- Password reset: forgot password form, email with JWT reset link, set new password page +- .env support: `python-dotenv` loads `.env` at startup, mail + JWT config via env vars +- Mail system: SMTP sender using stdlib (`smtplib`), supports plain/TLS/SSL +- TopBar dropdown z-index fix: user menu no longer hidden behind main content area +- JWT secret persistence: stable secret in `.env` survives server restarts +- 1586 Python tests + 185 Vitest tests (1771 total), build clean + +## Files Modified +- `src/duh/config/schema.py` — added `MailConfig`, `reset_token_expiry_minutes` to `AuthConfig` +- `src/duh/config/loader.py` — `.env` loading via `python-dotenv`, mail env var resolution +- `src/duh/api/auth.py` — `POST /api/auth/forgot-password` + `POST /api/auth/reset-password` +- `pyproject.toml` — added `python-dotenv>=1.0` +- `web/src/api/types.ts` — forgot/reset request/response types +- `web/src/api/client.ts` — `forgotPassword()` + `resetPassword()` API calls +- `web/src/pages/LoginPage.tsx` — "Forgot password?" flow with inline form +- `web/src/App.tsx` — `/reset-password` public route +- `web/src/pages/index.ts` — export `ResetPasswordPage` +- `web/src/components/layout/TopBar.tsx` — `relative z-20` to fix dropdown clipping + +## Files Created +- `src/duh/mail.py` — SMTP email sender (stdlib, no new deps beyond dotenv) +- `web/src/pages/ResetPasswordPage.tsx` — token-based password reset form +- `.env.example` — reference template for all env vars + +## Patterns Applied +- Extends existing `AuthConfig` in `config/schema.py` +- Follows existing auth route pattern in `api/auth.py` +- Reuses `GlassPanel`, `GlowButton` shared components on new pages +- Mail env var resolution mirrors provider API key resolution pattern in `loader.py` + +## Integration Points +- `api/auth.py:forgot_password` generates reset JWT, calls `mail.send_email()` +- `api/auth.py:reset_password` decodes JWT, updates `User.password_hash` +- Frontend: LoginPage → forgot flow → email → ResetPasswordPage → sign in +- `load_dotenv()` runs before all config resolution in `load_config()` + +## Architectural Decisions +- Used stdlib `smtplib` over aiosmtplib — reset email is a rare operation, sync is fine +- Reset token is a purpose-scoped JWT (`"purpose": "password_reset"`) with 15-min expiry +- Generic response on forgot-password ("If that email is registered...") prevents email enumeration +- `.env` env vars always override defaults (not just when field is empty) diff --git a/memory-bank/tasks/2026-03/README.md b/memory-bank/tasks/2026-03/README.md new file mode 100644 index 0000000..74ade11 --- /dev/null +++ b/memory-bank/tasks/2026-03/README.md @@ -0,0 +1,10 @@ +# Tasks — March 2026 + +## 2026-03-07: Password Reset + .env Support + TopBar Fix +- Password reset flow: forgot password form, SMTP email with JWT reset link, set new password page +- `.env` file support via `python-dotenv` for mail config and JWT secret +- `MailConfig` added to config schema with env var overrides +- TopBar user menu dropdown z-index fix (was clipped by main overflow) +- Stable JWT secret in `.env` for session persistence across server restarts +- Files: `mail.py`, `auth.py`, `schema.py`, `loader.py`, `LoginPage.tsx`, `ResetPasswordPage.tsx`, `TopBar.tsx` +- See: [070307_password-reset.md](./070307_password-reset.md) diff --git a/pyproject.toml b/pyproject.toml index ca39ecb..7605575 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "duh" -version = "0.5.0" +version = "0.6.0" description = "Multi-model consensus engine — because one LLM opinion isn't enough" requires-python = ">=3.11" dependencies = [ @@ -24,6 +24,7 @@ dependencies = [ "pyjwt>=2.8", "asyncpg>=0.29", "fpdf2>=2.7", + "python-dotenv>=1.0", ] [project.scripts] diff --git a/src/duh/__init__.py b/src/duh/__init__.py index 7eb2af6..0a30fa4 100644 --- a/src/duh/__init__.py +++ b/src/duh/__init__.py @@ -1,3 +1,3 @@ """duh — Multi-model consensus engine.""" -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/src/duh/api/app.py b/src/duh/api/app.py index 1549fd2..af7d467 100644 --- a/src/duh/api/app.py +++ b/src/duh/api/app.py @@ -41,7 +41,7 @@ def create_app(config: DuhConfig | None = None) -> FastAPI: app = FastAPI( title="duh", description="Multi-model consensus engine API", - version="0.5.0", + version="0.6.0", lifespan=lifespan, ) app.state.config = config diff --git a/src/duh/api/auth.py b/src/duh/api/auth.py index 870e177..6b7867f 100644 --- a/src/duh/api/auth.py +++ b/src/duh/api/auth.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from datetime import UTC, datetime, timedelta from typing import Any @@ -10,6 +11,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/auth", tags=["auth"]) # --- Password hashing --- @@ -178,6 +181,34 @@ async def login(body: LoginRequest, request: Request) -> TokenResponse: return TokenResponse(access_token=token, user_id=user.id, role=user.role) +class AuthStatusResponse(BaseModel): + auth_required: bool + + +@router.get("/status", response_model=AuthStatusResponse) +async def auth_status(request: Request) -> AuthStatusResponse: + """Check if authentication is required (dev mode detection).""" + from duh.memory.repository import MemoryRepository + + db_factory = request.app.state.db_factory + async with db_factory() as session: + repo = MemoryRepository(session) + keys = await repo.list_api_keys() + + # Auth is required if API keys or users exist + if keys: + return AuthStatusResponse(auth_required=True) + + from sqlalchemy import func, select + + from duh.memory.models import User + + async with db_factory() as session: + count = (await session.execute(select(func.count(User.id)))).scalar() or 0 + + return AuthStatusResponse(auth_required=count > 0) + + @router.get("/me", response_model=UserResponse) async def me(user: Any = Depends(get_current_user)) -> UserResponse: # noqa: B008 """Get current user info.""" @@ -188,3 +219,157 @@ async def me(user: Any = Depends(get_current_user)) -> UserResponse: # noqa: B0 role=user.role, is_active=user.is_active, ) + + +# --- Password reset --- + + +class ForgotPasswordRequest(BaseModel): + email: str + + +class ForgotPasswordResponse(BaseModel): + message: str + + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str + + +class ResetPasswordResponse(BaseModel): + message: str + + +def _create_reset_token(user_id: str, secret: str, expiry_minutes: int) -> str: + """Create a short-lived JWT for password reset.""" + payload = { + "sub": user_id, + "purpose": "password_reset", + "exp": datetime.now(UTC) + timedelta(minutes=expiry_minutes), + "iat": datetime.now(UTC), + } + return jwt.encode(payload, secret, algorithm="HS256") + + +def _decode_reset_token(token: str, secret: str) -> str: + """Decode a reset token and return user_id. Raises HTTPException.""" + try: + payload = jwt.decode(token, secret, algorithms=["HS256"]) + except jwt.ExpiredSignatureError as err: + raise HTTPException(status_code=400, detail="Reset link has expired") from err + except jwt.InvalidTokenError as err: + raise HTTPException(status_code=400, detail="Invalid reset link") from err + + if payload.get("purpose") != "password_reset": + raise HTTPException(status_code=400, detail="Invalid reset link") + + user_id: str | None = payload.get("sub") + if not user_id: + raise HTTPException(status_code=400, detail="Invalid reset link") + return user_id + + +@router.post("/forgot-password", response_model=ForgotPasswordResponse) +async def forgot_password( + body: ForgotPasswordRequest, request: Request +) -> ForgotPasswordResponse: + """Request a password reset email.""" + config = request.app.state.config + generic_msg = "If that email is registered, you will receive a reset link." + + from sqlalchemy import select + + from duh.memory.models import User + + db_factory = request.app.state.db_factory + async with db_factory() as session: + stmt = select(User).where( + User.email == body.email, + User.is_active == True, # noqa: E712 + ) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + + if user is None: + # Don't reveal whether the email exists + return ForgotPasswordResponse(message=generic_msg) + + token = _create_reset_token( + user.id, + config.auth.jwt_secret, + config.auth.reset_token_expiry_minutes, + ) + + # Build reset URL + origin = ( + request.headers.get("origin") + or request.headers.get("referer", "").rstrip("/") + or f"http://{config.api.host}:{config.api.port}" + ) + reset_url = f"{origin}/reset-password?token={token}" + + # Send email + try: + from duh.mail import send_email + + send_email( + config.mail, + to=user.email, + subject="Reset your duh password", + body_html=( + f"

Hi {user.display_name},

" + f"

Click the link below to reset your password. " + f"This link expires in " + f"{config.auth.reset_token_expiry_minutes} minutes.

" + f'

{reset_url}

' + f"

If you didn't request this, ignore this email.

" + ), + body_text=( + f"Hi {user.display_name},\n\n" + f"Reset your password:\n{reset_url}\n\n" + f"This link expires in " + f"{config.auth.reset_token_expiry_minutes} minutes.\n\n" + f"If you didn't request this, ignore this email." + ), + ) + except Exception as exc: + logger.exception("Failed to send password reset email to %s", user.email) + raise HTTPException( + status_code=503, + detail="Unable to send email. Check mail configuration.", + ) from exc + + return ForgotPasswordResponse(message=generic_msg) + + +@router.post("/reset-password", response_model=ResetPasswordResponse) +async def reset_password( + body: ResetPasswordRequest, request: Request +) -> ResetPasswordResponse: + """Reset password using a token from the reset email.""" + config = request.app.state.config + user_id = _decode_reset_token(body.token, config.auth.jwt_secret) + + from sqlalchemy import select + + from duh.memory.models import User + + db_factory = request.app.state.db_factory + async with db_factory() as session: + stmt = select(User).where( + User.id == user_id, + User.is_active == True, # noqa: E712 + ) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException(status_code=400, detail="Invalid reset link") + + user.password_hash = hash_password(body.new_password) + await session.commit() + + return ResetPasswordResponse( + message="Password has been reset. You can now sign in." + ) diff --git a/src/duh/api/middleware.py b/src/duh/api/middleware.py index 68ec91a..638a0b0 100644 --- a/src/duh/api/middleware.py +++ b/src/duh/api/middleware.py @@ -30,6 +30,7 @@ class APIKeyMiddleware(BaseHTTPMiddleware): "/api/metrics", "/api/auth/register", "/api/auth/login", + "/api/auth/status", "/docs", "/openapi.json", "/redoc", diff --git a/src/duh/api/routes/ask.py b/src/duh/api/routes/ask.py index 3d926bf..5e2c98f 100644 --- a/src/duh/api/routes/ask.py +++ b/src/duh/api/routes/ask.py @@ -83,7 +83,7 @@ async def _handle_consensus( # type: ignore[no-untyped-def] """Run the consensus protocol.""" from duh.cli.app import _run_consensus - decision, confidence, rigor, dissent, cost = await _run_consensus( + decision, confidence, rigor, dissent, cost, _overview = await _run_consensus( body.question, config, pm, @@ -153,7 +153,7 @@ async def _handle_decompose(body: AskRequest, config, pm) -> AskResponse: # typ if len(subtask_specs) == 1: from duh.cli.app import _run_consensus - decision, confidence, rigor, dissent, cost = await _run_consensus( + decision, confidence, rigor, dissent, cost, _overview = await _run_consensus( body.question, config, pm ) return AskResponse( diff --git a/src/duh/api/routes/threads.py b/src/duh/api/routes/threads.py index 7222b4d..ff47275 100644 --- a/src/duh/api/routes/threads.py +++ b/src/duh/api/routes/threads.py @@ -39,6 +39,8 @@ class ThreadSummaryResponse(BaseModel): question: str status: str created_at: str + has_outcome: bool = False + outcome: str | None = None class ThreadDetailResponse(BaseModel): @@ -68,15 +70,21 @@ async def list_threads( async with db_factory() as session: repo = MemoryRepository(session) threads = await repo.list_threads(status=status, limit=limit, offset=offset) - results = [ - ThreadSummaryResponse( - thread_id=t.id, - question=t.question, - status=t.status, - created_at=t.created_at.isoformat(), + results = [] + for t in threads: + outcomes = await repo.get_outcomes_for_thread(t.id) + has_outcome = len(outcomes) > 0 + outcome_val = outcomes[-1].result if outcomes else None + results.append( + ThreadSummaryResponse( + thread_id=t.id, + question=t.question, + status=t.status, + created_at=t.created_at.isoformat(), + has_outcome=has_outcome, + outcome=outcome_val, + ) ) - for t in threads - ] return ThreadListResponse(threads=results, total=len(results)) @@ -240,10 +248,15 @@ async def export_thread( votes = await repo.get_votes(thread_id) short_id = thread_id[:8] + overview_text = thread.summary.summary if thread.summary else None if format == "pdf": pdf_bytes = _format_thread_pdf( - thread, votes, content=content, include_dissent=dissent + thread, + votes, + content=content, + include_dissent=dissent, + overview=overview_text, ) return StreamingResponse( io.BytesIO(pdf_bytes), @@ -256,7 +269,11 @@ async def export_thread( ) md_text = _format_thread_markdown( - thread, votes, content=content, include_dissent=dissent + thread, + votes, + content=content, + include_dissent=dissent, + overview=overview_text, ) return StreamingResponse( io.BytesIO(md_text.encode()), diff --git a/src/duh/api/routes/ws.py b/src/duh/api/routes/ws.py index 39a5b50..2f094f1 100644 --- a/src/duh/api/routes/ws.py +++ b/src/duh/api/routes/ws.py @@ -97,6 +97,7 @@ async def _stream_consensus( """Run consensus loop and stream events to WebSocket.""" from duh.consensus.convergence import check_convergence from duh.consensus.handlers import ( + generate_overview, handle_challenge, handle_commit, handle_propose, @@ -155,6 +156,7 @@ async def _stream_consensus( } ) challenge_resps = await handle_challenge(ctx, pm, challengers) + succeeded = {ch.model_ref for ch in ctx.challenges} for i, ch in enumerate(ctx.challenges): resp_truncated = ( i < len(challenge_resps) and challenge_resps[i].finish_reason != "stop" @@ -167,6 +169,15 @@ async def _stream_consensus( "truncated": resp_truncated, } ) + # Notify about challengers that failed + for ref in challengers: + if ref not in succeeded: + await ws.send_json( + { + "type": "challenge_error", + "model": ref, + } + ) await ws.send_json({"type": "phase_complete", "phase": "CHALLENGE"}) # REVISE @@ -208,13 +219,16 @@ async def _stream_consensus( sm.transition(ConsensusState.COMPLETE) + # Generate executive overview (best-effort) + await generate_overview(ctx, pm) + # Persist to DB if available thread_id: str | None = None db_factory = getattr(ws.app.state, "db_factory", None) if db_factory is not None: try: thread_id = await _persist_consensus( - db_factory, question, ctx.round_history + db_factory, question, ctx.round_history, ctx.overview ) except Exception: logger.exception("Failed to persist consensus thread") @@ -228,6 +242,7 @@ async def _stream_consensus( "dissent": ctx.dissent, "cost": pm.total_cost, "thread_id": thread_id, + "overview": ctx.overview, } ) await ws.close() @@ -237,6 +252,7 @@ async def _persist_consensus( db_factory: object, question: str, round_history: list[RoundResult], + overview: str | None = None, ) -> str: """Persist consensus round history to the database. @@ -270,5 +286,8 @@ async def _persist_consensus( dissent=rr.dissent, ) + if overview: + await repo.save_thread_summary(thread.id, overview, "overview") + await session.commit() return str(thread.id) diff --git a/src/duh/cli/app.py b/src/duh/cli/app.py index b3c9ea3..c222e2b 100644 --- a/src/duh/cli/app.py +++ b/src/duh/cli/app.py @@ -209,13 +209,14 @@ async def _run_consensus( panel: list[str] | None = None, proposer_override: str | None = None, challengers_override: list[str] | None = None, -) -> tuple[str, float, float, str | None, float]: +) -> tuple[str, float, float, str | None, float, str | None]: """Run the full consensus loop. - Returns (decision, confidence, rigor, dissent, total_cost). + Returns (decision, confidence, rigor, dissent, total_cost, overview). """ from duh.consensus.convergence import check_convergence from duh.consensus.handlers import ( + generate_overview, handle_challenge, handle_commit, handle_propose, @@ -301,6 +302,9 @@ async def _run_consensus( sm.transition(ConsensusState.COMPLETE) + # Generate executive overview (best-effort) + await generate_overview(ctx, pm) + # Show tool usage if any if display and ctx.tool_calls_log: display.show_tool_use(ctx.tool_calls_log) @@ -311,6 +315,7 @@ async def _run_consensus( ctx.rigor, ctx.dissent, pm.total_cost, + ctx.overview, ) @@ -448,12 +453,14 @@ def ask( _error(str(e)) return # unreachable - decision, confidence, rigor, dissent, cost = result + decision, confidence, rigor, dissent, cost, overview = result from duh.cli.display import ConsensusDisplay display = ConsensusDisplay() - display.show_final_decision(decision, confidence, rigor, cost, dissent) + display.show_final_decision( + decision, confidence, rigor, cost, dissent, overview=overview + ) async def _ask_async( @@ -463,7 +470,7 @@ async def _ask_async( panel: list[str] | None = None, proposer_override: str | None = None, challengers_override: list[str] | None = None, -) -> tuple[str, float, float, str | None, float]: +) -> tuple[str, float, float, str | None, float, str | None]: """Async implementation for the ask command.""" from duh.cli.display import ConsensusDisplay @@ -570,10 +577,12 @@ async def _ask_auto_async( display = ConsensusDisplay() display.start() - decision, confidence, rigor, dissent, cost = await _run_consensus( + decision, confidence, rigor, dissent, cost, overview = await _run_consensus( question, config, pm, display=display ) - display.show_final_decision(decision, confidence, rigor, cost, dissent) + display.show_final_decision( + decision, confidence, rigor, cost, dissent, overview=overview + ) async def _ask_decompose_async( @@ -646,8 +655,10 @@ async def _ask_decompose_async( # Single-subtask optimization: skip synthesis if len(subtask_specs) == 1: result = await _run_consensus(question, config, pm, display=display) - decision, confidence, rigor, dissent, cost = result - display.show_final_decision(decision, confidence, rigor, cost, dissent) + decision, confidence, rigor, dissent, cost, overview = result + display.show_final_decision( + decision, confidence, rigor, cost, dissent, overview=overview + ) await engine.dispose() return @@ -1139,12 +1150,14 @@ def _format_thread_markdown( *, content: str = "full", include_dissent: bool = True, + overview: str | None = None, ) -> str: """Format a thread as Markdown for export. Args: content: "full" for complete report, "decision" for decision only. include_dissent: Whether to include the dissent section. + overview: Optional executive overview to include before Decision. """ lines: list[str] = [] created = thread.created_at.strftime("%Y-%m-%d") @@ -1161,6 +1174,12 @@ def _format_thread_markdown( lines.append(f"# Consensus: {thread.question}") lines.append("") + # Executive overview (before decision) + if overview: + lines.append("## Executive Overview") + lines.append(overview) + lines.append("") + # Decision section if final_decision: lines.append("## Decision") @@ -1234,6 +1253,7 @@ def _format_thread_pdf( *, content: str = "full", include_dissent: bool = True, + overview: str | None = None, ) -> bytes: """Format a thread as a research-paper quality PDF. @@ -1552,6 +1572,25 @@ def _callout_box( pages=1, ) + # -- Executive Overview section -- + if overview: + pdf.start_section("Executive Overview") + pdf.set_font(pdf._font_family, "B", 15) + pdf.cell(0, 8, "Executive Overview") + pdf.ln(8) + + overview_start_y = pdf.get_y() + pdf.set_left_margin(16) + pdf.set_x(16) + pdf.set_font(pdf._font_family, "", 11) + pdf.set_text_color(40, 40, 40) + _write_md(overview) + pdf.ln(4) + + _draw_accent_bar(overview_start_y, pdf.get_y(), (40, 120, 200)) + pdf.set_left_margin(10) + pdf.ln(4) + # -- Decision section -- if final_decision: pdf.start_section("Decision") @@ -1764,6 +1803,7 @@ async def _models_async(config: DuhConfig) -> None: click.echo( f" {m.display_name} ({m.model_id}) " f"ctx:{m.context_window:,} " + f"max_out:{m.max_output_tokens:,} " f"in:${m.input_cost_per_mtok}/Mtok " f"out:${m.output_cost_per_mtok}/Mtok{suffix}" ) @@ -2260,9 +2300,14 @@ async def _batch_async( confidence = vr.confidence rigor = vr.rigor else: - decision, confidence, rigor, _dissent, _cost = await _run_consensus( - question, config, pm - ) + ( + decision, + confidence, + rigor, + _dissent, + _cost, + _overview, + ) = await _run_consensus(question, config, pm) q_cost = pm.total_cost - cost_before total_cost += q_cost diff --git a/src/duh/cli/display.py b/src/duh/cli/display.py index ac34bba..ee2132c 100644 --- a/src/duh/cli/display.py +++ b/src/duh/cli/display.py @@ -366,15 +366,27 @@ def show_final_decision( rigor: float, cost: float, dissent: str | None, + *, + overview: str | None = None, ) -> None: """Display the final consensus decision (full, untruncated).""" self._console.print() self._console.rule(style="bright_white") + + if overview: + self._console.print( + Panel( + overview, + title=("[bold bright_white]Executive Overview[/bold bright_white]"), + border_style="bright_white", + ) + ) + self._console.print( Panel( decision, title="[bold bright_white]Decision[/bold bright_white]", - border_style="bright_white", + border_style="dim", ) ) self._console.print( diff --git a/src/duh/config/loader.py b/src/duh/config/loader.py index 558515a..9eca418 100644 --- a/src/duh/config/loader.py +++ b/src/duh/config/loader.py @@ -20,6 +20,8 @@ from pathlib import Path from typing import Any +from dotenv import load_dotenv + from duh.core.errors import ConfigError from .schema import DuhConfig @@ -107,6 +109,9 @@ def load_config( Raises: ConfigError: On invalid TOML, missing files, or validation failure. """ + # Load .env file (does not override existing env vars) + load_dotenv() + merged: dict[str, Any] = {} # Discover and merge config files @@ -138,4 +143,32 @@ def load_config( # Resolve API keys from env vars _resolve_api_keys(config) + # Auto-generate a dev JWT secret if none is configured + if not config.auth.jwt_secret: + env_secret = os.environ.get("DUH_JWT_SECRET") + if env_secret: + config.auth.jwt_secret = env_secret + else: + import secrets + + config.auth.jwt_secret = secrets.token_hex(32) + + # Resolve mail config from env vars (env always wins when set) + _mail_env_map = { + "host": "DUH_MAIL_HOST", + "port": "DUH_MAIL_PORT", + "username": "DUH_MAIL_USERNAME", + "password": "DUH_MAIL_PASSWORD", + "encryption": "DUH_MAIL_ENCRYPTION", + "from_address": "DUH_MAIL_FROM_ADDRESS", + "from_name": "DUH_MAIL_FROM_NAME", + } + for field, env_var in _mail_env_map.items(): + env_val = os.environ.get(env_var) + if env_val: + if field == "port": + setattr(config.mail, field, int(env_val)) + else: + setattr(config.mail, field, env_val) + return config diff --git a/src/duh/config/schema.py b/src/duh/config/schema.py index 5fcc5ff..4c5294c 100644 --- a/src/duh/config/schema.py +++ b/src/duh/config/schema.py @@ -101,12 +101,25 @@ class TaxonomyConfig(BaseModel): model_ref: str = "" +class MailConfig(BaseModel): + """Mail (SMTP) configuration for transactional emails.""" + + host: str = "" + port: int = 587 + username: str = "" + password: str = "" + encryption: str = "" # "tls", "ssl", or "" for none + from_address: str = "" + from_name: str = "duh" + + class AuthConfig(BaseModel): """Authentication configuration.""" jwt_secret: str = "" # must be set in production token_expiry_hours: int = 24 registration_enabled: bool = True + reset_token_expiry_minutes: int = 15 class APIConfig(BaseModel): @@ -153,3 +166,4 @@ class DuhConfig(BaseModel): taxonomy: TaxonomyConfig = Field(default_factory=TaxonomyConfig) api: APIConfig = Field(default_factory=APIConfig) auth: AuthConfig = Field(default_factory=AuthConfig) + mail: MailConfig = Field(default_factory=MailConfig) diff --git a/src/duh/consensus/handlers.py b/src/duh/consensus/handlers.py index aef2150..c3f6d4d 100644 --- a/src/duh/consensus/handlers.py +++ b/src/duh/consensus/handlers.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import logging from datetime import UTC, datetime from typing import TYPE_CHECKING @@ -15,6 +16,8 @@ from duh.core.errors import ConsensusError, InsufficientModelsError from duh.providers.base import PromptMessage +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from duh.consensus.machine import ConsensusContext from duh.providers.base import ModelResponse @@ -548,8 +551,10 @@ async def handle_challenge( challenges: list[ChallengeResult] = [] responses: list[ModelResponse] = [] - for result in raw_results: + for i, result in enumerate(raw_results): if isinstance(result, BaseException): + failed_ref = challenger_models[i] + logger.warning("Challenger %s failed: %s", failed_ref, result) continue model_ref, framing, response = result challenges.append( @@ -827,3 +832,54 @@ async def _classify_decision( } except (JSONExtractionError, Exception): return None + + +async def generate_overview( + ctx: ConsensusContext, + provider_manager: ProviderManager, +) -> str | None: + """Generate a concise executive overview of the consensus decision. + + Uses the cheapest model with ~2000 max_tokens. Returns None on failure + so callers can gracefully degrade. + """ + models = provider_manager.list_all_models() + if not models: + return None + + cheapest = min(models, key=lambda m: m.input_cost_per_mtok) + provider, model_id = provider_manager.get_provider(cheapest.model_ref) + + dissent_part = "" + if ctx.dissent: + dissent_part = f"\nDissenting view: {ctx.dissent}" + + prompt = ( + "Write a concise executive overview of this consensus decision " + "suitable for sharing on social media, a blog post, or LinkedIn. " + "Cover the question asked, the key points of debate, and the " + "conclusion reached. Write in a clear, engaging style. " + "Do not use hashtags.\n\n" + f"Question: {ctx.question}\n" + f"Decision: {ctx.decision}\n" + f"Confidence: {ctx.confidence:.0%}\n" + f"Rigor: {ctx.rigor:.0%}\n" + f"Rounds of debate: {len(ctx.round_history)}" + f"{dissent_part}" + ) + + try: + response = await provider.send( + [PromptMessage(role="user", content=prompt)], + model_id, + max_tokens=2000, + temperature=0.5, + ) + provider_manager.record_usage(cheapest, response.usage) + overview = response.content.strip() + if overview: + ctx.overview = overview + return overview + return None + except Exception: + return None diff --git a/src/duh/consensus/machine.py b/src/duh/consensus/machine.py index 5ed42fd..a5bb5f6 100644 --- a/src/duh/consensus/machine.py +++ b/src/duh/consensus/machine.py @@ -93,6 +93,7 @@ class ConsensusContext: confidence: float = 0.0 rigor: float = 0.0 dissent: str | None = None + overview: str | None = None converged: bool = False # History diff --git a/src/duh/mail.py b/src/duh/mail.py new file mode 100644 index 0000000..baeccbf --- /dev/null +++ b/src/duh/mail.py @@ -0,0 +1,61 @@ +"""Transactional email sender using stdlib SMTP.""" + +from __future__ import annotations + +import logging +import smtplib +import ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from duh.config.schema import MailConfig + +logger = logging.getLogger(__name__) + + +def send_email( + config: MailConfig, + to: str, + subject: str, + body_html: str, + body_text: str | None = None, +) -> None: + """Send a transactional email via SMTP. + + Raises smtplib.SMTPException on failure. + """ + if not config.host: + msg = "Mail not configured (no SMTP host). Set DUH_MAIL_HOST." + raise smtplib.SMTPException(msg) + + message = MIMEMultipart("alternative") + message["Subject"] = subject + message["From"] = ( + f"{config.from_name} <{config.from_address}>" + if config.from_name + else config.from_address + ) + message["To"] = to + + if body_text: + message.attach(MIMEText(body_text, "plain")) + message.attach(MIMEText(body_html, "html")) + + if config.encryption == "ssl": + ctx = ssl.create_default_context() + with smtplib.SMTP_SSL(config.host, config.port, context=ctx) as server: + if config.username: + server.login(config.username, config.password) + server.sendmail(config.from_address, to, message.as_string()) + else: + with smtplib.SMTP(config.host, config.port) as server: + if config.encryption == "tls": + ctx = ssl.create_default_context() + server.starttls(context=ctx) + if config.username: + server.login(config.username, config.password) + server.sendmail(config.from_address, to, message.as_string()) + + logger.info("Sent email to %s: %s", to, subject) diff --git a/src/duh/mcp/server.py b/src/duh/mcp/server.py index f91663d..7e6de1a 100644 --- a/src/duh/mcp/server.py +++ b/src/duh/mcp/server.py @@ -135,7 +135,7 @@ async def _handle_ask(args: dict) -> list[TextContent]: # type: ignore[type-arg ) ] else: - decision, confidence, rigor, dissent, cost = await _run_consensus( + decision, confidence, rigor, dissent, cost, _overview = await _run_consensus( question, config, pm ) return [ diff --git a/src/duh/memory/backup.py b/src/duh/memory/backup.py index 231a8f8..da3f203 100644 --- a/src/duh/memory/backup.py +++ b/src/duh/memory/backup.py @@ -83,7 +83,7 @@ async def backup_json(session: AsyncSession, dest: Path) -> Path: pass data: dict[str, Any] = { - "version": "0.5.0", + "version": "0.6.0", "exported_at": datetime.now(UTC).isoformat(), "tables": {}, } diff --git a/src/duh/providers/anthropic.py b/src/duh/providers/anthropic.py index a486f8f..8a79e62 100644 --- a/src/duh/providers/anthropic.py +++ b/src/duh/providers/anthropic.py @@ -16,13 +16,13 @@ ProviderTimeoutError, ) from duh.providers.base import ( - ModelCapability, ModelInfo, ModelResponse, StreamChunk, TokenUsage, ToolCallData, ) +from duh.providers.catalog import MODEL_CATALOG, PROVIDER_CAPS if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -30,50 +30,8 @@ from duh.providers.base import PromptMessage PROVIDER_ID = "anthropic" - -# Known Claude models with metadata. -# Updated as new models release; list_models() returns these. -_KNOWN_MODELS: list[dict[str, Any]] = [ - { - "model_id": "claude-opus-4-6", - "display_name": "Claude Opus 4.6", - "context_window": 200_000, - "max_output_tokens": 128_000, - "input_cost_per_mtok": 5.0, - "output_cost_per_mtok": 25.0, - }, - { - "model_id": "claude-sonnet-4-6", - "display_name": "Claude Sonnet 4.6", - "context_window": 200_000, - "max_output_tokens": 64_000, - "input_cost_per_mtok": 3.0, - "output_cost_per_mtok": 15.0, - }, - { - "model_id": "claude-sonnet-4-5-20250929", - "display_name": "Claude Sonnet 4.5", - "context_window": 200_000, - "max_output_tokens": 64_000, - "input_cost_per_mtok": 3.0, - "output_cost_per_mtok": 15.0, - }, - { - "model_id": "claude-haiku-4-5-20251001", - "display_name": "Claude Haiku 4.5", - "context_window": 200_000, - "max_output_tokens": 64_000, - "input_cost_per_mtok": 1.0, - "output_cost_per_mtok": 5.0, - }, -] - -_DEFAULT_CAPS = ( - ModelCapability.TEXT - | ModelCapability.STREAMING - | ModelCapability.SYSTEM_PROMPT - | ModelCapability.JSON_MODE -) +_KNOWN_MODELS = MODEL_CATALOG[PROVIDER_ID] +_DEFAULT_CAPS = PROVIDER_CAPS[PROVIDER_ID] def _map_error(e: anthropic.APIError) -> Exception: diff --git a/src/duh/providers/catalog.py b/src/duh/providers/catalog.py new file mode 100644 index 0000000..8c99980 --- /dev/null +++ b/src/duh/providers/catalog.py @@ -0,0 +1,236 @@ +"""Centralized model catalog for all providers. + +One file to update when models change. Each provider imports its +models from here instead of defining its own ``_KNOWN_MODELS`` list. +""" + +from __future__ import annotations + +from typing import Any + +from duh.providers.base import ModelCapability + +# ── Per-provider default capabilities ────────────────────────── + +PROVIDER_CAPS: dict[str, ModelCapability] = { + "anthropic": ( + ModelCapability.TEXT + | ModelCapability.STREAMING + | ModelCapability.SYSTEM_PROMPT + | ModelCapability.JSON_MODE + ), + "openai": ( + ModelCapability.TEXT + | ModelCapability.STREAMING + | ModelCapability.SYSTEM_PROMPT + | ModelCapability.JSON_MODE + ), + "google": ( + ModelCapability.TEXT + | ModelCapability.STREAMING + | ModelCapability.SYSTEM_PROMPT + | ModelCapability.JSON_MODE + ), + "mistral": ( + ModelCapability.TEXT + | ModelCapability.STREAMING + | ModelCapability.SYSTEM_PROMPT + | ModelCapability.JSON_MODE + ), + "perplexity": ( + ModelCapability.TEXT + | ModelCapability.STREAMING + | ModelCapability.SYSTEM_PROMPT + | ModelCapability.JSON_MODE + ), +} + +# ── Model catalog keyed by provider_id ───────────────────────── + +MODEL_CATALOG: dict[str, list[dict[str, Any]]] = { + "anthropic": [ + { + "model_id": "claude-opus-4-6", + "display_name": "Claude Opus 4.6", + "context_window": 200_000, + "max_output_tokens": 128_000, + "input_cost_per_mtok": 5.00, + "output_cost_per_mtok": 25.00, + }, + { + "model_id": "claude-sonnet-4-6", + "display_name": "Claude Sonnet 4.6", + "context_window": 200_000, + "max_output_tokens": 64_000, + "input_cost_per_mtok": 3.00, + "output_cost_per_mtok": 15.00, + }, + { + "model_id": "claude-sonnet-4-5-20250929", + "display_name": "Claude Sonnet 4.5", + "context_window": 200_000, + "max_output_tokens": 64_000, + "input_cost_per_mtok": 3.00, + "output_cost_per_mtok": 15.00, + }, + { + "model_id": "claude-haiku-4-5-20251001", + "display_name": "Claude Haiku 4.5", + "context_window": 200_000, + "max_output_tokens": 64_000, + "input_cost_per_mtok": 1.00, + "output_cost_per_mtok": 5.00, + }, + ], + "openai": [ + { + "model_id": "gpt-5.2", + "display_name": "GPT-5.2", + "context_window": 400_000, + "max_output_tokens": 128_000, + "input_cost_per_mtok": 1.75, + "output_cost_per_mtok": 14.00, + }, + { + "model_id": "gpt-5-mini", + "display_name": "GPT-5 mini", + "context_window": 400_000, + "max_output_tokens": 128_000, + "input_cost_per_mtok": 0.25, + "output_cost_per_mtok": 2.00, + }, + { + "model_id": "o3", + "display_name": "o3", + "context_window": 200_000, + "max_output_tokens": 100_000, + "input_cost_per_mtok": 2.00, + "output_cost_per_mtok": 8.00, + }, + ], + "google": [ + { + "model_id": "gemini-3.1-pro-preview", + "display_name": "Gemini 3.1 Pro (Preview)", + "context_window": 1_048_576, + "max_output_tokens": 65_536, + "input_cost_per_mtok": 2.00, + "output_cost_per_mtok": 12.00, + }, + { + "model_id": "gemini-3-pro-preview", + "display_name": "Gemini 3 Pro (Preview)", + "context_window": 1_048_576, + "max_output_tokens": 65_536, + "input_cost_per_mtok": 2.00, + "output_cost_per_mtok": 12.00, + }, + { + "model_id": "gemini-3-flash-preview", + "display_name": "Gemini 3 Flash (Preview)", + "context_window": 1_048_576, + "max_output_tokens": 65_536, + "input_cost_per_mtok": 0.50, + "output_cost_per_mtok": 3.00, + }, + { + "model_id": "gemini-2.5-pro", + "display_name": "Gemini 2.5 Pro", + "context_window": 1_048_576, + "max_output_tokens": 65_536, + "input_cost_per_mtok": 1.25, + "output_cost_per_mtok": 10.00, + }, + { + "model_id": "gemini-2.5-flash", + "display_name": "Gemini 2.5 Flash", + "context_window": 1_048_576, + "max_output_tokens": 65_536, + "input_cost_per_mtok": 0.30, + "output_cost_per_mtok": 2.50, + }, + ], + "mistral": [ + { + "model_id": "mistral-large-latest", + "display_name": "Mistral Large", + "context_window": 128_000, + "max_output_tokens": 32_000, + "input_cost_per_mtok": 2.0, + "output_cost_per_mtok": 6.0, + }, + { + "model_id": "mistral-medium-latest", + "display_name": "Mistral Medium", + "context_window": 128_000, + "max_output_tokens": 32_000, + "input_cost_per_mtok": 2.7, + "output_cost_per_mtok": 8.1, + }, + { + "model_id": "mistral-small-latest", + "display_name": "Mistral Small", + "context_window": 128_000, + "max_output_tokens": 32_000, + "input_cost_per_mtok": 0.2, + "output_cost_per_mtok": 0.6, + }, + { + "model_id": "codestral-latest", + "display_name": "Codestral", + "context_window": 256_000, + "max_output_tokens": 32_000, + "input_cost_per_mtok": 0.3, + "output_cost_per_mtok": 0.9, + }, + ], + "perplexity": [ + { + "model_id": "sonar", + "display_name": "Sonar", + "context_window": 128_000, + "max_output_tokens": 8_192, + "input_cost_per_mtok": 1.0, + "output_cost_per_mtok": 1.0, + }, + { + "model_id": "sonar-pro", + "display_name": "Sonar Pro", + "context_window": 200_000, + "max_output_tokens": 8_192, + "input_cost_per_mtok": 3.0, + "output_cost_per_mtok": 15.0, + }, + { + "model_id": "sonar-reasoning-pro", + "display_name": "Sonar Reasoning Pro", + "context_window": 128_000, + "max_output_tokens": 8_192, + "input_cost_per_mtok": 2.0, + "output_cost_per_mtok": 8.0, + }, + { + "model_id": "sonar-deep-research", + "display_name": "Sonar Deep Research", + "context_window": 128_000, + "max_output_tokens": 8_192, + "input_cost_per_mtok": 2.0, + "output_cost_per_mtok": 8.0, + }, + ], +} + +# ── OpenAI reasoning models (no temperature support) ─────────── + +NO_TEMPERATURE_MODELS: set[str] = { + "o3", + "o3-mini", + "o4-mini", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", +} + +# ── Providers that are challenger-only (not proposer-eligible) ── + +CHALLENGER_ONLY_PROVIDERS: set[str] = {"perplexity"} diff --git a/src/duh/providers/google.py b/src/duh/providers/google.py index 82ba8d0..30a400e 100644 --- a/src/duh/providers/google.py +++ b/src/duh/providers/google.py @@ -16,13 +16,13 @@ ProviderTimeoutError, ) from duh.providers.base import ( - ModelCapability, ModelInfo, ModelResponse, StreamChunk, TokenUsage, ToolCallData, ) +from duh.providers.catalog import MODEL_CATALOG, PROVIDER_CAPS if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -30,48 +30,8 @@ from duh.providers.base import PromptMessage PROVIDER_ID = "google" - -_KNOWN_MODELS: list[dict[str, Any]] = [ - { - "model_id": "gemini-3-pro-preview", - "display_name": "Gemini 3 Pro (Preview)", - "context_window": 1_048_576, - "max_output_tokens": 65_536, - "input_cost_per_mtok": 2.00, - "output_cost_per_mtok": 12.00, - }, - { - "model_id": "gemini-3-flash-preview", - "display_name": "Gemini 3 Flash (Preview)", - "context_window": 1_048_576, - "max_output_tokens": 65_536, - "input_cost_per_mtok": 0.50, - "output_cost_per_mtok": 3.00, - }, - { - "model_id": "gemini-2.5-pro", - "display_name": "Gemini 2.5 Pro", - "context_window": 1_048_576, - "max_output_tokens": 65_536, - "input_cost_per_mtok": 1.25, - "output_cost_per_mtok": 10.00, - }, - { - "model_id": "gemini-2.5-flash", - "display_name": "Gemini 2.5 Flash", - "context_window": 1_048_576, - "max_output_tokens": 65_536, - "input_cost_per_mtok": 0.30, - "output_cost_per_mtok": 2.50, - }, -] - -_DEFAULT_CAPS = ( - ModelCapability.TEXT - | ModelCapability.STREAMING - | ModelCapability.SYSTEM_PROMPT - | ModelCapability.JSON_MODE -) +_KNOWN_MODELS = MODEL_CATALOG[PROVIDER_ID] +_DEFAULT_CAPS = PROVIDER_CAPS[PROVIDER_ID] def _map_error(e: Exception) -> Exception: diff --git a/src/duh/providers/mistral.py b/src/duh/providers/mistral.py index 5c36f3b..7aae04b 100644 --- a/src/duh/providers/mistral.py +++ b/src/duh/providers/mistral.py @@ -17,13 +17,13 @@ ProviderTimeoutError, ) from duh.providers.base import ( - ModelCapability, ModelInfo, ModelResponse, StreamChunk, TokenUsage, ToolCallData, ) +from duh.providers.catalog import MODEL_CATALOG, PROVIDER_CAPS if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -31,48 +31,8 @@ from duh.providers.base import PromptMessage PROVIDER_ID = "mistral" - -_KNOWN_MODELS: list[dict[str, Any]] = [ - { - "model_id": "mistral-large-latest", - "display_name": "Mistral Large", - "context_window": 128_000, - "max_output_tokens": 32_000, - "input_cost_per_mtok": 2.0, - "output_cost_per_mtok": 6.0, - }, - { - "model_id": "mistral-medium-latest", - "display_name": "Mistral Medium", - "context_window": 128_000, - "max_output_tokens": 32_000, - "input_cost_per_mtok": 2.7, - "output_cost_per_mtok": 8.1, - }, - { - "model_id": "mistral-small-latest", - "display_name": "Mistral Small", - "context_window": 128_000, - "max_output_tokens": 32_000, - "input_cost_per_mtok": 0.2, - "output_cost_per_mtok": 0.6, - }, - { - "model_id": "codestral-latest", - "display_name": "Codestral", - "context_window": 256_000, - "max_output_tokens": 32_000, - "input_cost_per_mtok": 0.3, - "output_cost_per_mtok": 0.9, - }, -] - -_DEFAULT_CAPS = ( - ModelCapability.TEXT - | ModelCapability.STREAMING - | ModelCapability.SYSTEM_PROMPT - | ModelCapability.JSON_MODE -) +_KNOWN_MODELS = MODEL_CATALOG[PROVIDER_ID] +_DEFAULT_CAPS = PROVIDER_CAPS[PROVIDER_ID] def _map_error(e: Exception) -> Exception: diff --git a/src/duh/providers/openai.py b/src/duh/providers/openai.py index 8aec493..00b6bcc 100644 --- a/src/duh/providers/openai.py +++ b/src/duh/providers/openai.py @@ -16,13 +16,17 @@ ProviderTimeoutError, ) from duh.providers.base import ( - ModelCapability, ModelInfo, ModelResponse, StreamChunk, TokenUsage, ToolCallData, ) +from duh.providers.catalog import ( + MODEL_CATALOG, + NO_TEMPERATURE_MODELS, + PROVIDER_CAPS, +) if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -30,55 +34,9 @@ from duh.providers.base import PromptMessage PROVIDER_ID = "openai" - -# Known OpenAI models with metadata. -# Updated as new models release; list_models() returns these. -_KNOWN_MODELS: list[dict[str, Any]] = [ - { - "model_id": "gpt-5.2", - "display_name": "GPT-5.2", - "context_window": 400_000, - "max_output_tokens": 128_000, - "input_cost_per_mtok": 1.75, - "output_cost_per_mtok": 14.00, - }, - { - "model_id": "gpt-5-mini", - "display_name": "GPT-5 mini", - "context_window": 400_000, - "max_output_tokens": 128_000, - "input_cost_per_mtok": 0.25, - "output_cost_per_mtok": 2.00, - }, - { - "model_id": "o3", - "display_name": "o3", - "context_window": 200_000, - "max_output_tokens": 100_000, - "input_cost_per_mtok": 2.00, - "output_cost_per_mtok": 8.00, - }, -] - -_DEFAULT_CAPS = ( - ModelCapability.TEXT - | ModelCapability.STREAMING - | ModelCapability.SYSTEM_PROMPT - | ModelCapability.JSON_MODE -) - -# Reasoning models that don't support temperature, top_p, or -# presence/frequency penalty. Only temperature=1 (the default) is accepted. -# Chat-class models (gpt-5.2, gpt-5-chat-latest, gpt-4.1) DO support these. -# See: https://community.openai.com/t/temperature-in-gpt-5-models/1337133 -_NO_TEMPERATURE_MODELS = { - "o3", - "o3-mini", - "o4-mini", - "gpt-5", - "gpt-5-mini", - "gpt-5-nano", -} +_KNOWN_MODELS = MODEL_CATALOG[PROVIDER_ID] +_DEFAULT_CAPS = PROVIDER_CAPS[PROVIDER_ID] +_NO_TEMPERATURE_MODELS = NO_TEMPERATURE_MODELS def _map_error(e: openai.APIError) -> Exception: diff --git a/src/duh/providers/perplexity.py b/src/duh/providers/perplexity.py index 335a863..6cd7567 100644 --- a/src/duh/providers/perplexity.py +++ b/src/duh/providers/perplexity.py @@ -16,13 +16,13 @@ ProviderTimeoutError, ) from duh.providers.base import ( - ModelCapability, ModelInfo, ModelResponse, StreamChunk, TokenUsage, ToolCallData, ) +from duh.providers.catalog import MODEL_CATALOG, PROVIDER_CAPS if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -30,41 +30,8 @@ from duh.providers.base import PromptMessage PROVIDER_ID = "perplexity" - -# Known Perplexity models with metadata. -_KNOWN_MODELS: list[dict[str, Any]] = [ - { - "model_id": "sonar", - "display_name": "Sonar", - "context_window": 128_000, - "max_output_tokens": 8_192, - "input_cost_per_mtok": 1.0, - "output_cost_per_mtok": 1.0, - }, - { - "model_id": "sonar-pro", - "display_name": "Sonar Pro", - "context_window": 200_000, - "max_output_tokens": 8_192, - "input_cost_per_mtok": 3.0, - "output_cost_per_mtok": 15.0, - }, - { - "model_id": "sonar-deep-research", - "display_name": "Sonar Deep Research", - "context_window": 128_000, - "max_output_tokens": 8_192, - "input_cost_per_mtok": 2.0, - "output_cost_per_mtok": 8.0, - }, -] - -_DEFAULT_CAPS = ( - ModelCapability.TEXT - | ModelCapability.STREAMING - | ModelCapability.SYSTEM_PROMPT - | ModelCapability.JSON_MODE -) +_KNOWN_MODELS = MODEL_CATALOG[PROVIDER_ID] +_DEFAULT_CAPS = PROVIDER_CAPS[PROVIDER_ID] def _map_error(e: openai.APIError) -> Exception: @@ -139,6 +106,13 @@ async def list_models(self) -> list[ModelInfo]: for m in _KNOWN_MODELS ] + def _max_output_for(self, model_id: str) -> int: + """Return the max output tokens for a model, defaulting to 8192.""" + for m in _KNOWN_MODELS: + if m["model_id"] == model_id: + return int(m["max_output_tokens"]) + return 8_192 + async def send( self, messages: list[PromptMessage], @@ -151,10 +125,11 @@ async def send( tools: list[dict[str, object]] | None = None, ) -> ModelResponse: api_messages = _build_messages(messages) + clamped = min(max_tokens, self._max_output_for(model_id)) kwargs: dict[str, Any] = { "model": model_id, - "max_completion_tokens": max_tokens, + "max_tokens": clamped, "messages": api_messages, "temperature": temperature, } @@ -228,10 +203,11 @@ async def stream( stop_sequences: list[str] | None = None, ) -> AsyncIterator[StreamChunk]: api_messages = _build_messages(messages) + clamped = min(max_tokens, self._max_output_for(model_id)) kwargs: dict[str, Any] = { "model": model_id, - "max_completion_tokens": max_tokens, + "max_tokens": clamped, "messages": api_messages, "temperature": temperature, "stream_options": {"include_usage": True}, @@ -267,7 +243,7 @@ async def health_check(self) -> bool: try: await self._client.chat.completions.create( model="sonar", - max_completion_tokens=1, + max_tokens=1, messages=[{"role": "user", "content": "ping"}], ) except Exception: diff --git a/tests/unit/test_backup.py b/tests/unit/test_backup.py index 561087f..826f2d8 100644 --- a/tests/unit/test_backup.py +++ b/tests/unit/test_backup.py @@ -171,7 +171,7 @@ async def _run() -> Path: result = asyncio.run(_run()) data = json.loads(result.read_text()) - assert data["version"] == "0.5.0" + assert data["version"] == "0.6.0" assert "exported_at" in data # Verify exported_at is a valid ISO timestamp assert "T" in data["exported_at"] @@ -263,7 +263,7 @@ def test_backup_json_via_cli(self, runner: CliRunner, tmp_path: Path) -> None: assert dest.exists() data = json.loads(dest.read_text()) - assert data["version"] == "0.5.0" + assert data["version"] == "0.6.0" asyncio.run(engine.dispose()) def test_backup_format_auto_sqlite(self, runner: CliRunner, tmp_path: Path) -> None: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index b6bef3b..0679876 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -30,7 +30,7 @@ def test_version(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["--version"]) assert result.exit_code == 0 assert "duh" in result.output - assert "0.5.0" in result.output + assert "0.6.0" in result.output def test_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["--help"]) @@ -75,6 +75,7 @@ def test_displays_decision( 1.0, None, 0.0042, + None, ) result = runner.invoke(cli, ["ask", "What database?"]) @@ -101,6 +102,7 @@ def test_displays_dissent( 1.0, "[model-a]: PostgreSQL would be better for scale.", 0.01, + None, ) result = runner.invoke(cli, ["ask", "What database?"]) @@ -121,7 +123,7 @@ def test_no_dissent_when_none( from duh.config.schema import DuhConfig mock_config.return_value = DuhConfig() - mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0) + mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0, None) result = runner.invoke(cli, ["ask", "Question?"]) @@ -140,7 +142,7 @@ def test_rounds_option( config = DuhConfig() mock_config.return_value = config - mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0) + mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0, None) result = runner.invoke(cli, ["ask", "--rounds", "5", "Question?"]) @@ -641,7 +643,7 @@ def test_ask_full_loop(self, runner: CliRunner) -> None: async def fake_ask( question: str, cfg: Any, **kwargs: Any - ) -> tuple[str, float, float, str | None, float]: + ) -> tuple[str, float, float, str | None, float, str | None]: pm = ProviderManager() await pm.register(provider) from duh.cli.app import _run_consensus diff --git a/tests/unit/test_cli_batch.py b/tests/unit/test_cli_batch.py index b9c2956..85c0d19 100644 --- a/tests/unit/test_cli_batch.py +++ b/tests/unit/test_cli_batch.py @@ -452,10 +452,10 @@ async def fake_consensus( pm: Any, display: Any = None, tool_registry: Any = None, - ) -> tuple[str, float, float, str | None, float]: + ) -> tuple[str, float, float, str | None, float, str | None]: nonlocal consensus_called consensus_called = True - return ("Use SQLite.", 0.85, 1.0, None, 0.01) + return ("Use SQLite.", 0.85, 1.0, None, 0.01, None) with ( patch("duh.cli.app.load_config", return_value=config), @@ -546,8 +546,8 @@ async def fake_consensus( pm: Any, display: Any = None, tool_registry: Any = None, - ) -> tuple[str, float, float, str | None, float]: - return ("Answer.", 0.9, 1.0, None, 0.01) + ) -> tuple[str, float, float, str | None, float, str | None]: + return ("Answer.", 0.9, 1.0, None, 0.01, None) with ( patch("duh.cli.app.load_config", return_value=config), @@ -601,12 +601,12 @@ async def fake_consensus( pm: Any, display: Any = None, tool_registry: Any = None, - ) -> tuple[str, float, float, str | None, float]: + ) -> tuple[str, float, float, str | None, float, str | None]: nonlocal call_count call_count += 1 if question == "Q2": raise RuntimeError("Provider timeout") - return ("Answer.", 0.9, 1.0, None, 0.01) + return ("Answer.", 0.9, 1.0, None, 0.01, None) with ( patch("duh.cli.app.load_config", return_value=config), @@ -650,10 +650,10 @@ async def fake_consensus( pm: Any, display: Any = None, tool_registry: Any = None, - ) -> tuple[str, float, float, str | None, float]: + ) -> tuple[str, float, float, str | None, float, str | None]: if question == "Q2": raise RuntimeError("Model unavailable") - return ("Answer.", 0.9, 1.0, None, 0.01) + return ("Answer.", 0.9, 1.0, None, 0.01, None) with ( patch("duh.cli.app.load_config", return_value=config), diff --git a/tests/unit/test_cli_tools.py b/tests/unit/test_cli_tools.py index 3e24d94..adce8ff 100644 --- a/tests/unit/test_cli_tools.py +++ b/tests/unit/test_cli_tools.py @@ -242,7 +242,7 @@ def test_tools_enabled_passes_registry( config = DuhConfig(tools=ToolsConfig(enabled=True)) mock_config.return_value = config mock_providers.return_value.list_all_models.return_value = ["model1"] - mock_consensus.return_value = ("Answer", 0.9, 1.0, None, 0.01) + mock_consensus.return_value = ("Answer", 0.9, 1.0, None, 0.01, None) runner.invoke(cli, ["ask", "test question"]) @@ -263,7 +263,7 @@ def test_tools_disabled_passes_none( config = DuhConfig(tools=ToolsConfig(enabled=False)) mock_config.return_value = config mock_providers.return_value.list_all_models.return_value = ["model1"] - mock_consensus.return_value = ("Answer", 0.9, 1.0, None, 0.01) + mock_consensus.return_value = ("Answer", 0.9, 1.0, None, 0.01, None) runner.invoke(cli, ["ask", "test question"]) diff --git a/tests/unit/test_cli_voting.py b/tests/unit/test_cli_voting.py index a57b303..d7dcc36 100644 --- a/tests/unit/test_cli_voting.py +++ b/tests/unit/test_cli_voting.py @@ -147,7 +147,7 @@ def test_default_protocol_is_consensus( from duh.config.schema import DuhConfig mock_config.return_value = DuhConfig() - mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0) + mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0, None) result = runner.invoke(cli, ["ask", "Question?"]) assert result.exit_code == 0 diff --git a/tests/unit/test_mcp_server.py b/tests/unit/test_mcp_server.py index 01dd139..9afee17 100644 --- a/tests/unit/test_mcp_server.py +++ b/tests/unit/test_mcp_server.py @@ -177,7 +177,7 @@ async def test_consensus_protocol(self) -> None: patch( "duh.cli.app._run_consensus", new_callable=AsyncMock, - return_value=("Use SQLite.", 0.9, 1.0, "minor dissent", 0.05), + return_value=("Use SQLite.", 0.9, 1.0, "minor dissent", 0.05, None), ), ): result = await _handle_ask({"question": "What DB?", "rounds": 2}) diff --git a/tests/unit/test_providers_google.py b/tests/unit/test_providers_google.py index 75a9e6b..12497df 100644 --- a/tests/unit/test_providers_google.py +++ b/tests/unit/test_providers_google.py @@ -73,8 +73,9 @@ def test_provider_id(): async def test_list_models(): prov = GoogleProvider(client=_make_client()) models = await prov.list_models() - assert len(models) == 4 + assert len(models) == 5 ids = {m.model_id for m in models} + assert "gemini-3.1-pro-preview" in ids assert "gemini-3-pro-preview" in ids assert "gemini-3-flash-preview" in ids assert "gemini-2.5-pro" in ids diff --git a/tests/unit/test_providers_perplexity.py b/tests/unit/test_providers_perplexity.py index 77b336a..c0bce60 100644 --- a/tests/unit/test_providers_perplexity.py +++ b/tests/unit/test_providers_perplexity.py @@ -126,10 +126,10 @@ def test_satisfies_protocol(self): class TestListModels: - async def test_returns_three_models(self): + async def test_returns_four_models(self): provider = PerplexityProvider(client=_make_client()) models = await provider.list_models() - assert len(models) == 3 + assert len(models) == 4 assert all(isinstance(m, ModelInfo) for m in models) async def test_all_models_are_perplexity(self): @@ -141,7 +141,12 @@ async def test_expected_model_ids(self): provider = PerplexityProvider(client=_make_client()) models = await provider.list_models() ids = {m.model_id for m in models} - assert ids == {"sonar", "sonar-pro", "sonar-deep-research"} + assert ids == { + "sonar", + "sonar-pro", + "sonar-reasoning-pro", + "sonar-deep-research", + } async def test_models_have_costs(self): provider = PerplexityProvider(client=_make_client()) @@ -190,7 +195,7 @@ async def test_passes_correct_base_url_model_messages(self): await provider.send(msgs, "sonar-pro", max_tokens=1000, temperature=0.5) call_kwargs = client.chat.completions.create.call_args.kwargs assert call_kwargs["model"] == "sonar-pro" - assert call_kwargs["max_completion_tokens"] == 1000 + assert call_kwargs["max_tokens"] == 1000 assert call_kwargs["temperature"] == 0.5 assert call_kwargs["messages"][0]["role"] == "system" diff --git a/tests/unit/test_restore.py b/tests/unit/test_restore.py index d1899b6..75dea4f 100644 --- a/tests/unit/test_restore.py +++ b/tests/unit/test_restore.py @@ -54,7 +54,7 @@ def _make_json_backup( tmp_path: Path, tables: dict[str, list[dict[str, Any]]] | None = None, *, - version: str = "0.5.0", + version: str = "0.6.0", filename: str = "backup.json", ) -> Path: """Create a JSON backup file for testing.""" @@ -74,7 +74,7 @@ def _make_json_backup( class TestDetectBackupFormat: def test_json_file(self, tmp_path: Path) -> None: f = tmp_path / "backup.json" - f.write_text('{"version": "0.5.0", "tables": {}}') + f.write_text('{"version": "0.6.0", "tables": {}}') assert detect_backup_format(f) == "json" def test_json_array(self, tmp_path: Path) -> None: @@ -346,7 +346,7 @@ async def _verify() -> None: def test_restore_validates_structure(self, tmp_path: Path) -> None: """Missing 'tables' key raises ValueError.""" bad_backup = tmp_path / "bad.json" - bad_backup.write_text(json.dumps({"version": "0.5.0"}), encoding="utf-8") + bad_backup.write_text(json.dumps({"version": "0.6.0"}), encoding="utf-8") factory, engine = _make_async_session() diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py index f424738..7f5eed5 100644 --- a/tests/unit/test_smoke.py +++ b/tests/unit/test_smoke.py @@ -7,14 +7,14 @@ def test_version_string(): - assert __version__ == "0.5.0" + assert __version__ == "0.6.0" def test_cli_version(): runner = CliRunner() result = runner.invoke(cli, ["--version"]) assert result.exit_code == 0 - assert "0.5.0" in result.output + assert "0.6.0" in result.output def test_cli_help(): diff --git a/uv.lock b/uv.lock index d6d0929..c7ddcc3 100644 --- a/uv.lock +++ b/uv.lock @@ -604,7 +604,7 @@ wheels = [ [[package]] name = "duh" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -624,6 +624,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "python-dotenv" }, { name = "rich" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn", extra = ["standard"] }, @@ -665,6 +666,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.0" }, { name = "pyjwt", specifier = ">=2.8" }, + { name = "python-dotenv", specifier = ">=1.0" }, { name = "rich", specifier = ">=13.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index b7e9acb..892bf12 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,10 @@ +import { useEffect } from 'react' import { BrowserRouter, Routes, Route } from 'react-router-dom' import { Shell } from '@/components/layout' -import { ErrorBoundary } from '@/components/shared' +import { ErrorBoundary, ProtectedRoute } from '@/components/shared' import { + LoginPage, + ResetPasswordPage, ConsensusPage, ThreadsPage, ThreadDetailPage, @@ -10,14 +13,29 @@ import { PreferencesPage, SharePage, } from '@/pages' +import { useAuthStore } from '@/stores' export function App() { + const initialize = useAuthStore((s) => s.initialize) + + useEffect(() => { + initialize() + }, [initialize]) + return ( + } /> + } /> } /> - }> + + + + } + > } /> } /> } /> diff --git a/web/src/__tests__/auth-components.test.tsx b/web/src/__tests__/auth-components.test.tsx new file mode 100644 index 0000000..2ee4d7e --- /dev/null +++ b/web/src/__tests__/auth-components.test.tsx @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, act } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { useAuthStore } from '@/stores/auth' + +// ── Mock the API client ── +vi.mock('@/api/client', () => ({ + api: { + login: vi.fn(), + register: vi.fn(), + me: vi.fn(), + authStatus: vi.fn(), + health: vi.fn(), + }, +})) + +import { LoginPage } from '@/pages/LoginPage' +import { ProtectedRoute } from '@/components/shared/ProtectedRoute' + +describe('LoginPage', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + useAuthStore.setState({ + token: null, + user: null, + status: 'idle', + error: null, + authRequired: true, + }) + }) + + it('renders login form by default', () => { + render( + + + , + ) + + expect(screen.getByRole('heading', { name: 'Sign In' })).toBeTruthy() + expect(screen.getByPlaceholderText('you@example.com')).toBeTruthy() + expect(screen.getByPlaceholderText('\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022')).toBeTruthy() + expect(screen.getByText('Register')).toBeTruthy() + }) + + it('shows error message when login fails', async () => { + useAuthStore.setState({ + status: 'error', + error: 'Invalid credentials', + }) + + render( + + + , + ) + + expect(screen.getByText('Invalid credentials')).toBeTruthy() + }) + + it('shows display name field in register mode', async () => { + render( + + + , + ) + + // Click Register link + const registerLink = screen.getByText('Register') + act(() => { registerLink.click() }) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Create Account' })).toBeTruthy() + expect(screen.getByPlaceholderText('Your name')).toBeTruthy() + }) + }) + + it('redirects when authenticated', () => { + useAuthStore.setState({ + status: 'authenticated', + user: { id: 'u1', email: 'a@b.com', display_name: 'A', role: 'user', is_active: true }, + }) + + render( + + + , + ) + + // When authenticated, LoginPage renders Navigate to "/" + // In test env, we just verify the component doesn't render the form + expect(screen.queryByText('Sign In')).toBeNull() + }) +}) + +describe('ProtectedRoute', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('renders children when authenticated', () => { + useAuthStore.setState({ + status: 'authenticated', + authRequired: true, + user: { id: 'u1', email: 'a@b.com', display_name: 'A', role: 'user', is_active: true }, + }) + + render( + + +
Protected Content
+
+
, + ) + + expect(screen.getByText('Protected Content')).toBeTruthy() + }) + + it('renders children when auth not required (dev mode)', () => { + useAuthStore.setState({ + status: 'authenticated', + authRequired: false, + user: { id: 'guest', email: '', display_name: 'Guest', role: 'admin', is_active: true }, + }) + + render( + + +
Dev Mode Content
+
+
, + ) + + expect(screen.getByText('Dev Mode Content')).toBeTruthy() + }) + + it('redirects to login when not authenticated', () => { + useAuthStore.setState({ + status: 'idle', + authRequired: true, + }) + + render( + + +
Should Not Show
+
+
, + ) + + expect(screen.queryByText('Should Not Show')).toBeNull() + }) + + it('shows loading skeleton when auth status unknown', () => { + useAuthStore.setState({ + authRequired: null, + }) + + render( + + +
Should Not Show
+
+
, + ) + + expect(screen.queryByText('Should Not Show')).toBeNull() + }) +}) diff --git a/web/src/__tests__/auth-store.test.ts b/web/src/__tests__/auth-store.test.ts new file mode 100644 index 0000000..4959633 --- /dev/null +++ b/web/src/__tests__/auth-store.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// ── Mock the API client ── +vi.mock('@/api/client', () => ({ + api: { + login: vi.fn(), + register: vi.fn(), + me: vi.fn(), + authStatus: vi.fn(), + }, +})) + +import { useAuthStore } from '@/stores/auth' +import { api } from '@/api/client' + +const mockedApi = vi.mocked(api) + +describe('useAuthStore', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + useAuthStore.setState({ + token: null, + user: null, + status: 'idle', + error: null, + authRequired: null, + }) + }) + + it('has correct initial state', () => { + const state = useAuthStore.getState() + expect(state.token).toBeNull() + expect(state.user).toBeNull() + expect(state.status).toBe('idle') + expect(state.error).toBeNull() + expect(state.authRequired).toBeNull() + }) + + describe('login', () => { + it('sets token and user on successful login', async () => { + mockedApi.login.mockResolvedValue({ + access_token: 'test-token', + token_type: 'bearer', + user_id: 'u1', + role: 'user', + }) + mockedApi.me.mockResolvedValue({ + id: 'u1', + email: 'test@test.com', + display_name: 'Test User', + role: 'user', + is_active: true, + }) + + await useAuthStore.getState().login('test@test.com', 'pass123') + + const state = useAuthStore.getState() + expect(state.status).toBe('authenticated') + expect(state.token).toBe('test-token') + expect(state.user?.email).toBe('test@test.com') + expect(state.error).toBeNull() + expect(localStorage.getItem('duh_token')).toBe('test-token') + }) + + it('sets error on failed login', async () => { + mockedApi.login.mockRejectedValue(new Error('Invalid credentials')) + + await useAuthStore.getState().login('bad@test.com', 'wrong') + + const state = useAuthStore.getState() + expect(state.status).toBe('error') + expect(state.error).toBe('Invalid credentials') + expect(state.token).toBeNull() + }) + }) + + describe('register', () => { + it('sets token and user on successful register', async () => { + mockedApi.register.mockResolvedValue({ + access_token: 'new-token', + token_type: 'bearer', + user_id: 'u2', + role: 'user', + }) + mockedApi.me.mockResolvedValue({ + id: 'u2', + email: 'new@test.com', + display_name: 'New User', + role: 'user', + is_active: true, + }) + + await useAuthStore.getState().register('new@test.com', 'pass', 'New User') + + const state = useAuthStore.getState() + expect(state.status).toBe('authenticated') + expect(state.token).toBe('new-token') + expect(state.user?.display_name).toBe('New User') + expect(localStorage.getItem('duh_token')).toBe('new-token') + }) + + it('sets error on failed register', async () => { + mockedApi.register.mockRejectedValue(new Error('Email already registered')) + + await useAuthStore.getState().register('dup@test.com', 'pass', 'Dup') + + const state = useAuthStore.getState() + expect(state.status).toBe('error') + expect(state.error).toBe('Email already registered') + }) + }) + + describe('logout', () => { + it('clears token and user', () => { + useAuthStore.setState({ + token: 'some-token', + user: { id: 'u1', email: 'x@x.com', display_name: 'X', role: 'user', is_active: true }, + status: 'authenticated', + }) + localStorage.setItem('duh_token', 'some-token') + + useAuthStore.getState().logout() + + const state = useAuthStore.getState() + expect(state.token).toBeNull() + expect(state.user).toBeNull() + expect(state.status).toBe('idle') + expect(localStorage.getItem('duh_token')).toBeNull() + }) + }) + + describe('initialize', () => { + it('sets guest user when auth not required (dev mode)', async () => { + mockedApi.authStatus.mockResolvedValue({ auth_required: false }) + + await useAuthStore.getState().initialize() + + const state = useAuthStore.getState() + expect(state.authRequired).toBe(false) + expect(state.status).toBe('authenticated') + expect(state.user?.id).toBe('guest') + }) + + it('validates existing token when auth required', async () => { + localStorage.setItem('duh_token', 'existing-token') + mockedApi.authStatus.mockResolvedValue({ auth_required: true }) + mockedApi.me.mockResolvedValue({ + id: 'u1', + email: 'test@test.com', + display_name: 'Test', + role: 'user', + is_active: true, + }) + + await useAuthStore.getState().initialize() + + const state = useAuthStore.getState() + expect(state.authRequired).toBe(true) + expect(state.status).toBe('authenticated') + expect(state.user?.email).toBe('test@test.com') + }) + + it('clears invalid token and sets idle', async () => { + localStorage.setItem('duh_token', 'expired-token') + mockedApi.authStatus.mockResolvedValue({ auth_required: true }) + mockedApi.me.mockRejectedValue(new Error('Token expired')) + + await useAuthStore.getState().initialize() + + const state = useAuthStore.getState() + expect(state.status).toBe('idle') + expect(state.token).toBeNull() + expect(localStorage.getItem('duh_token')).toBeNull() + }) + + it('sets idle when auth required but no token', async () => { + mockedApi.authStatus.mockResolvedValue({ auth_required: true }) + + await useAuthStore.getState().initialize() + + const state = useAuthStore.getState() + expect(state.authRequired).toBe(true) + expect(state.status).toBe('idle') + }) + + it('falls back to guest when auth status endpoint fails', async () => { + mockedApi.authStatus.mockRejectedValue(new Error('Not found')) + + await useAuthStore.getState().initialize() + + const state = useAuthStore.getState() + expect(state.authRequired).toBe(false) + expect(state.status).toBe('authenticated') + expect(state.user?.id).toBe('guest') + }) + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9ded7df..f276725 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,32 +1,54 @@ import type { AskRequest, AskResponse, + AuthStatusResponse, CalibrationResponse, CostResponse, DecisionSpaceResponse, FeedbackRequest, FeedbackResponse, + ForgotPasswordRequest, + ForgotPasswordResponse, HealthResponse, + LoginRequest, ModelsResponse, RecallResponse, + RegisterRequest, + ResetPasswordRequest, + ResetPasswordResponse, ThreadDetail, ThreadListResponse, + TokenResponse, + UserInfo, } from './types' import { ApiError } from './types' const BASE = '/api' +const TOKEN_KEY = 'duh_token' + +function getAuthHeaders(): Record { + const token = localStorage.getItem(TOKEN_KEY) + if (token) { + return { Authorization: `Bearer ${token}` } + } + return {} +} async function request(path: string, options?: RequestInit): Promise { const url = `${BASE}${path}` const res = await fetch(url, { headers: { 'Content-Type': 'application/json', + ...getAuthHeaders(), ...options?.headers, }, ...options, }) if (!res.ok) { + if (res.status === 401) { + localStorage.removeItem(TOKEN_KEY) + } let detail = res.statusText try { const body = await res.json() @@ -43,10 +65,49 @@ async function request(path: string, options?: RequestInit): Promise { // ── Endpoints ───────────────────────────────────────────── export const api = { + // Auth + login(body: LoginRequest): Promise { + return request('/auth/login', { + method: 'POST', + body: JSON.stringify(body), + }) + }, + + register(body: RegisterRequest): Promise { + return request('/auth/register', { + method: 'POST', + body: JSON.stringify(body), + }) + }, + + me(): Promise { + return request('/auth/me') + }, + + authStatus(): Promise { + return request('/auth/status') + }, + + forgotPassword(body: ForgotPasswordRequest): Promise { + return request('/auth/forgot-password', { + method: 'POST', + body: JSON.stringify(body), + }) + }, + + resetPassword(body: ResetPasswordRequest): Promise { + return request('/auth/reset-password', { + method: 'POST', + body: JSON.stringify(body), + }) + }, + + // Health health(): Promise { return request('/health') }, + // Consensus ask(body: AskRequest): Promise { return request('/ask', { method: 'POST', @@ -54,6 +115,7 @@ export const api = { }) }, + // Threads listThreads(params?: { status?: string limit?: number diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 0963a8f..88c259b 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1,3 +1,52 @@ +// ── Auth types ──────────────────────────────────────────── + +export interface LoginRequest { + email: string + password: string +} + +export interface RegisterRequest { + email: string + password: string + display_name: string +} + +export interface TokenResponse { + access_token: string + token_type: string + user_id: string + role: string +} + +export interface UserInfo { + id: string + email: string + display_name: string + role: string + is_active: boolean +} + +export interface AuthStatusResponse { + auth_required: boolean +} + +export interface ForgotPasswordRequest { + email: string +} + +export interface ForgotPasswordResponse { + message: string +} + +export interface ResetPasswordRequest { + token: string + new_password: string +} + +export interface ResetPasswordResponse { + message: string +} + // ── Request types ───────────────────────────────────────── export interface AskRequest { @@ -31,6 +80,8 @@ export interface ThreadSummary { question: string status: string created_at: string + has_outcome?: boolean + outcome?: string | null } export interface ThreadListResponse { @@ -179,6 +230,7 @@ export type WSEventType = | 'phase_start' | 'phase_complete' | 'challenge' + | 'challenge_error' | 'commit' | 'complete' | 'error' @@ -223,6 +275,12 @@ export interface WSComplete { dissent: string | null cost: number thread_id: string | null + overview: string | null +} + +export interface WSChallengeError { + type: 'challenge_error' + model: string } export interface WSError { @@ -234,6 +292,7 @@ export type WSEvent = | WSPhaseStart | WSPhaseComplete | WSChallenge + | WSChallengeError | WSCommit | WSComplete | WSError diff --git a/web/src/api/websocket.ts b/web/src/api/websocket.ts index 0a420fe..58cea2c 100644 --- a/web/src/api/websocket.ts +++ b/web/src/api/websocket.ts @@ -34,6 +34,10 @@ export class ConsensusWebSocket { rounds: options.rounds ?? 3, protocol: options.protocol ?? 'consensus', } + const token = localStorage.getItem('duh_token') + if (token) { + payload.token = token + } if (options.modelSelection?.panel?.length) { payload.panel = options.modelSelection.panel } diff --git a/web/src/components/consensus/ConsensusComplete.tsx b/web/src/components/consensus/ConsensusComplete.tsx index b0dd297..76affb6 100644 --- a/web/src/components/consensus/ConsensusComplete.tsx +++ b/web/src/components/consensus/ConsensusComplete.tsx @@ -13,6 +13,7 @@ interface ConsensusCompleteProps { dissent: string | null cost: number | null collapsible?: boolean + overview: string | null } export function generateExportMarkdown( @@ -25,10 +26,18 @@ export function generateExportMarkdown( rounds: RoundData[], content: 'full' | 'decision', includeDissent: boolean, + overview?: string | null, ): string { const lines: string[] = [] lines.push(`# Consensus: ${question ?? 'Unknown'}`) lines.push('') + + if (overview) { + lines.push('## Executive Overview') + lines.push(overview) + lines.push('') + } + lines.push('## Decision') lines.push(decision) lines.push('') @@ -89,19 +98,19 @@ function downloadFile(content: string | Blob, filename: string, mimeType: string URL.revokeObjectURL(url) } -export function ConsensusComplete({ decision, confidence, rigor, dissent, cost, collapsible }: ConsensusCompleteProps) { +export function ConsensusComplete({ decision, confidence, rigor, dissent, cost, collapsible, overview }: ConsensusCompleteProps) { const [copied, setCopied] = useState(false) const [exportOpen, setExportOpen] = useState(false) const { question, rounds, threadId } = useConsensusStore() const handleCopy = async () => { - await navigator.clipboard.writeText(decision) + await navigator.clipboard.writeText(overview ?? decision) setCopied(true) setTimeout(() => setCopied(false), 2000) } const handleExportMarkdown = (content: 'full' | 'decision') => { - const md = generateExportMarkdown(question, decision, confidence, rigor, dissent, cost, rounds, content, true) + const md = generateExportMarkdown(question, decision, confidence, rigor, dissent, cost, rounds, content, true, overview) downloadFile(md, `consensus-${content}.md`, 'text/markdown') setExportOpen(false) } @@ -129,7 +138,18 @@ export function ConsensusComplete({ decision, confidence, rigor, dissent, cost, const body = ( <> - {decision} + {overview ? ( + <> + {overview} +
+ Full Decision} defaultOpen={false}> + {decision} + +
+ + ) : ( + {decision} + )}
diff --git a/web/src/components/consensus/ConsensusPanel.tsx b/web/src/components/consensus/ConsensusPanel.tsx index 13ff2c3..45d990c 100644 --- a/web/src/components/consensus/ConsensusPanel.tsx +++ b/web/src/components/consensus/ConsensusPanel.tsx @@ -8,7 +8,7 @@ import { CostTicker } from './CostTicker' export function ConsensusPanel() { const { status, error, currentPhase, currentRound, rounds, - decision, confidence, rigor, dissent, cost, + decision, confidence, rigor, dissent, cost, overview, startConsensus, reset, } = useConsensusStore() @@ -40,6 +40,7 @@ export function ConsensusPanel() { dissent={dissent} cost={cost} collapsible + overview={overview} />
)} diff --git a/web/src/components/consensus/PhaseCard.tsx b/web/src/components/consensus/PhaseCard.tsx index 95cda12..c2d54fb 100644 --- a/web/src/components/consensus/PhaseCard.tsx +++ b/web/src/components/consensus/PhaseCard.tsx @@ -8,7 +8,7 @@ interface PhaseCardProps { models?: string[] content?: string | null isActive?: boolean - challenges?: Array<{ model: string; content: string; truncated?: boolean }> + challenges?: Array<{ model: string; content: string; truncated?: boolean; error?: boolean }> collapsible?: boolean defaultOpen?: boolean truncated?: boolean @@ -43,12 +43,17 @@ export function PhaseCard({ phase, model, models, content, isActive, challenges, {challenges.map((ch, i) => ( } - className="pl-3 border-l-2 border-[var(--color-amber)]/30" + defaultOpen={!collapsible && !ch.error} + header={ + <> + + {ch.error && failed} + + } + className={`pl-3 border-l-2 ${ch.error ? 'border-[var(--color-danger)]/30' : 'border-[var(--color-amber)]/30'}`} > -
- {ch.content} +
+ {ch.error ? {ch.content} : {ch.content}}
))} diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index 143f751..db1c31b 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -42,7 +42,7 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
- v0.4.0 + v0.6.0
) diff --git a/web/src/components/layout/TopBar.tsx b/web/src/components/layout/TopBar.tsx index 045e785..da49304 100644 --- a/web/src/components/layout/TopBar.tsx +++ b/web/src/components/layout/TopBar.tsx @@ -1,8 +1,12 @@ import { useState, useEffect } from 'react' import { api } from '@/api/client' +import { useAuthStore } from '@/stores' +import { Badge } from '@/components/shared' export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) { const [healthy, setHealthy] = useState(null) + const { user, authRequired, logout } = useAuthStore() + const [menuOpen, setMenuOpen] = useState(false) useEffect(() => { api.health() @@ -18,8 +22,15 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) { return () => clearInterval(interval) }, []) + const handleLogout = () => { + logout() + window.location.href = '/login' + } + + const showUserMenu = authRequired && user && user.id !== 'guest' + return ( -
+
+ + {showUserMenu && ( +
+ + + {menuOpen && ( + <> + {/* Invisible backdrop — closes menu on any outside click */} +
setMenuOpen(false)} + /> +
+
+

{user.display_name}

+

{user.email}

+
+ +
+ + )} +
+ )}
) diff --git a/web/src/components/shared/ProtectedRoute.tsx b/web/src/components/shared/ProtectedRoute.tsx new file mode 100644 index 0000000..f7d9973 --- /dev/null +++ b/web/src/components/shared/ProtectedRoute.tsx @@ -0,0 +1,43 @@ +import { Navigate } from 'react-router-dom' +import { useAuthStore } from '@/stores' +import { Skeleton } from './Skeleton' + +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { status, authRequired } = useAuthStore() + + // Still checking auth status + if (authRequired === null) { + return ( +
+
+ + +
+
+ ) + } + + // Auth not required (dev mode) — always pass through + if (!authRequired) { + return <>{children} + } + + // Auth required but still loading + if (status === 'loading') { + return ( +
+
+ + +
+
+ ) + } + + // Auth required but not authenticated + if (status !== 'authenticated') { + return + } + + return <>{children} +} diff --git a/web/src/components/shared/index.ts b/web/src/components/shared/index.ts index 20bfd7e..7efaca6 100644 --- a/web/src/components/shared/index.ts +++ b/web/src/components/shared/index.ts @@ -9,3 +9,4 @@ export { PageTransition } from './PageTransition' export { Markdown } from './Markdown' export { ExportMenu } from './ExportMenu' export { Disclosure } from './Disclosure' +export { ProtectedRoute } from './ProtectedRoute' diff --git a/web/src/components/threads/ThreadCard.tsx b/web/src/components/threads/ThreadCard.tsx index 6f874bf..90240ce 100644 --- a/web/src/components/threads/ThreadCard.tsx +++ b/web/src/components/threads/ThreadCard.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { GlassPanel, Badge } from '@/components/shared' +import { useThreadsStore } from '@/stores' import type { ThreadSummary } from '@/api/types' function formatDate(iso: string): string { @@ -13,8 +15,39 @@ const statusVariant: Record = { failed: 'red', } +const outcomeLabels: Record = { + success: { label: 'Success', variant: 'green' }, + failure: { label: 'Failure', variant: 'red' }, + partial: { label: 'Partial', variant: 'cyan' }, +} + export function ThreadCard({ thread }: { thread: ThreadSummary }) { const navigate = useNavigate() + const submitFeedback = useThreadsStore((s) => s.submitFeedback) + const fetchThreads = useThreadsStore((s) => s.fetchThreads) + const [submitting, setSubmitting] = useState(null) + const [recorded, setRecorded] = useState(thread.has_outcome ?? false) + const [outcome, setOutcome] = useState(thread.outcome ?? null) + + const handleFeedback = async ( + e: React.MouseEvent, + result: 'success' | 'failure' | 'partial', + ) => { + e.stopPropagation() + setSubmitting(result) + try { + await submitFeedback(thread.thread_id, result) + setRecorded(true) + setOutcome(result) + fetchThreads() + } catch { + // Silently fail — user can retry + } finally { + setSubmitting(null) + } + } + + const showFeedback = thread.status === 'complete' && !recorded return (
navigate(`/threads/${thread.thread_id}`)}> @@ -27,15 +60,88 @@ export function ThreadCard({ thread }: { thread: ThreadSummary }) {

{thread.question}

- - {thread.status} - +
+ {recorded && outcome && outcomeLabels[outcome] && ( + + {outcomeLabels[outcome].label} + + )} + + {thread.status} + +
-
- {formatDate(thread.created_at)} - {thread.thread_id.slice(0, 8)} +
+
+ {formatDate(thread.created_at)} + {thread.thread_id.slice(0, 8)} +
+ {showFeedback && ( +
e.stopPropagation()} + > + handleFeedback(e, 'success')} + color="green" + /> + handleFeedback(e, 'partial')} + color="cyan" + /> + handleFeedback(e, 'failure')} + color="red" + /> +
+ )}
) } + +function FeedbackButton({ + label, + title, + loading, + disabled, + onClick, + color, +}: { + label: string + title: string + loading: boolean + disabled: boolean + onClick: (e: React.MouseEvent) => void + color: 'green' | 'cyan' | 'red' +}) { + const colorMap = { + green: 'text-[var(--color-green)] hover:bg-[rgba(0,255,136,0.1)] border-[rgba(0,255,136,0.2)]', + cyan: 'text-[var(--color-primary)] hover:bg-[rgba(0,212,255,0.1)] border-[rgba(0,212,255,0.2)]', + red: 'text-[var(--color-red)] hover:bg-[rgba(255,59,79,0.1)] border-[rgba(255,59,79,0.2)]', + } + + return ( + + ) +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..87147eb --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -0,0 +1,220 @@ +import { useState } from 'react' +import { Navigate } from 'react-router-dom' +import { GlassPanel, GlowButton } from '@/components/shared' +import { useAuthStore } from '@/stores' +import { api } from '@/api/client' + +export function LoginPage() { + const { status, error, login, register } = useAuthStore() + const [mode, setMode] = useState<'login' | 'register' | 'forgot'>('login') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [displayName, setDisplayName] = useState('') + const [forgotMessage, setForgotMessage] = useState(null) + const [forgotError, setForgotError] = useState(null) + const [forgotLoading, setForgotLoading] = useState(false) + + if (status === 'authenticated') { + return + } + + const handleSubmit = async (e: { preventDefault: () => void }) => { + e.preventDefault() + if (mode === 'login') { + await login(email, password) + } else if (mode === 'register') { + await register(email, password, displayName) + } + } + + const handleForgotSubmit = async (e: { preventDefault: () => void }) => { + e.preventDefault() + setForgotLoading(true) + setForgotError(null) + setForgotMessage(null) + try { + const res = await api.forgotPassword({ email }) + setForgotMessage(res.message) + } catch (err) { + setForgotError((err as Error).message) + } finally { + setForgotLoading(false) + } + } + + const inputClass = "w-full px-3 py-2 rounded-[var(--radius-sm)] bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text)] font-mono text-sm focus:outline-none focus:border-[var(--color-primary)] focus:ring-1 focus:ring-[var(--color-primary)] transition-colors" + + return ( +
+
+
+

+ duh +

+

+ consensus engine +

+
+ + + {mode === 'forgot' ? ( +
+

+ Reset Password +

+

+ Enter your email and we'll send a reset link. +

+ +
+ + setEmail(e.target.value)} + required + className={inputClass} + placeholder="you@example.com" + /> +
+ + {forgotMessage && ( +

+ {forgotMessage} +

+ )} + + {forgotError && ( +

+ {forgotError} +

+ )} + + + Send Reset Link + + +

+ +

+
+ ) : ( +
+

+ {mode === 'login' ? 'Sign In' : 'Create Account'} +

+ + {mode === 'register' && ( +
+ + setDisplayName(e.target.value)} + required + className={inputClass} + placeholder="Your name" + /> +
+ )} + +
+ + setEmail(e.target.value)} + required + className={inputClass} + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + className={inputClass} + placeholder="••••••••" + /> +
+ + {mode === 'login' && ( +
+ +
+ )} + + {error && ( +

+ {error} +

+ )} + + + {mode === 'login' ? 'Sign In' : 'Create Account'} + + +

+ {mode === 'login' ? ( + <> + No account?{' '} + + + ) : ( + <> + Have an account?{' '} + + + )} +

+
+ )} +
+
+
+ ) +} diff --git a/web/src/pages/ResetPasswordPage.tsx b/web/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..d054944 --- /dev/null +++ b/web/src/pages/ResetPasswordPage.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react' +import { useSearchParams, Link } from 'react-router-dom' +import { GlassPanel, GlowButton } from '@/components/shared' +import { api } from '@/api/client' + +export function ResetPasswordPage() { + const [searchParams] = useSearchParams() + const token = searchParams.get('token') || '' + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState(null) + const [error, setError] = useState(null) + + const handleSubmit = async (e: { preventDefault: () => void }) => { + e.preventDefault() + if (password !== confirm) { + setError('Passwords do not match.') + return + } + if (password.length < 8) { + setError('Password must be at least 8 characters.') + return + } + setLoading(true) + setError(null) + setMessage(null) + try { + const res = await api.resetPassword({ token, new_password: password }) + setMessage(res.message) + } catch (err) { + setError((err as Error).message) + } finally { + setLoading(false) + } + } + + const inputClass = "w-full px-3 py-2 rounded-[var(--radius-sm)] bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text)] font-mono text-sm focus:outline-none focus:border-[var(--color-primary)] focus:ring-1 focus:ring-[var(--color-primary)] transition-colors" + + if (!token) { + return ( +
+ +

+ Invalid or missing reset link. +

+ + Back to Sign In + +
+
+ ) + } + + return ( +
+
+
+

+ duh +

+

+ consensus engine +

+
+ + + {message ? ( +
+

+ {message} +

+ + Sign In + +
+ ) : ( +
+

+ Set New Password +

+ +
+ + setPassword(e.target.value)} + required + minLength={8} + className={inputClass} + placeholder="••••••••" + /> +
+ +
+ + setConfirm(e.target.value)} + required + minLength={8} + className={inputClass} + placeholder="••••••••" + /> +
+ + {error && ( +

+ {error} +

+ )} + + + Reset Password + + +

+ + Back to Sign In + +

+
+ )} +
+
+
+ ) +} diff --git a/web/src/pages/index.ts b/web/src/pages/index.ts index 9683931..be217d6 100644 --- a/web/src/pages/index.ts +++ b/web/src/pages/index.ts @@ -1,3 +1,5 @@ +export { LoginPage } from './LoginPage' +export { ResetPasswordPage } from './ResetPasswordPage' export { ConsensusPage } from './ConsensusPage' export { ThreadsPage } from './ThreadsPage' export { ThreadDetailPage } from './ThreadDetailPage' diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts new file mode 100644 index 0000000..4466f62 --- /dev/null +++ b/web/src/stores/auth.ts @@ -0,0 +1,130 @@ +import { create } from 'zustand' +import { api } from '@/api/client' +import type { UserInfo } from '@/api/types' + +export type AuthStatus = 'idle' | 'loading' | 'authenticated' | 'error' + +const TOKEN_KEY = 'duh_token' + +interface AuthState { + token: string | null + user: UserInfo | null + status: AuthStatus + error: string | null + authRequired: boolean | null + + login: (email: string, password: string) => Promise + register: (email: string, password: string, displayName: string) => Promise + logout: () => void + initialize: () => Promise +} + +export const useAuthStore = create((set) => ({ + token: null, + user: null, + status: 'idle', + error: null, + authRequired: null, + + login: async (email, password) => { + set({ status: 'loading', error: null }) + try { + const res = await api.login({ email, password }) + localStorage.setItem(TOKEN_KEY, res.access_token) + const user = await api.me() + set({ + token: res.access_token, + user, + status: 'authenticated', + error: null, + }) + } catch (e) { + set({ status: 'error', error: (e as Error).message }) + } + }, + + register: async (email, password, displayName) => { + set({ status: 'loading', error: null }) + try { + const res = await api.register({ + email, + password, + display_name: displayName, + }) + localStorage.setItem(TOKEN_KEY, res.access_token) + const user = await api.me() + set({ + token: res.access_token, + user, + status: 'authenticated', + error: null, + }) + } catch (e) { + set({ status: 'error', error: (e as Error).message }) + } + }, + + logout: () => { + localStorage.removeItem(TOKEN_KEY) + set({ + token: null, + user: null, + status: 'idle', + error: null, + }) + }, + + initialize: async () => { + // Check if auth is required + try { + const { auth_required } = await api.authStatus() + set({ authRequired: auth_required }) + + if (!auth_required) { + // Dev mode: allow guest access + set({ + status: 'authenticated', + user: { + id: 'guest', + email: '', + display_name: 'Guest', + role: 'admin', + is_active: true, + }, + }) + return + } + } catch { + // If auth status endpoint doesn't exist, assume auth not required + set({ + authRequired: false, + status: 'authenticated', + user: { + id: 'guest', + email: '', + display_name: 'Guest', + role: 'admin', + is_active: true, + }, + }) + return + } + + // Auth is required — check for existing token + const token = localStorage.getItem(TOKEN_KEY) + if (!token) { + set({ status: 'idle' }) + return + } + + set({ token, status: 'loading' }) + try { + const user = await api.me() + set({ user, status: 'authenticated' }) + } catch { + // Token invalid or expired + localStorage.removeItem(TOKEN_KEY) + set({ token: null, status: 'idle' }) + } + }, +})) diff --git a/web/src/stores/consensus.ts b/web/src/stores/consensus.ts index 5ecfd4f..ec64a7d 100644 --- a/web/src/stores/consensus.ts +++ b/web/src/stores/consensus.ts @@ -13,6 +13,7 @@ export interface ChallengeEntry { model: string content: string truncated?: boolean + error?: boolean } export interface RoundData { @@ -49,6 +50,7 @@ interface ConsensusState { dissent: string | null cost: number | null threadId: string | null + overview: string | null // Actions startConsensus: (question: string, rounds?: number, protocol?: string, modelSelection?: ModelSelectionOptions) => void @@ -87,6 +89,7 @@ export const useConsensusStore = create((set, get) => ({ dissent: null, cost: null, threadId: null, + overview: null as string | null, startConsensus: (question, rounds = 3, protocol = 'consensus', modelSelection?) => { set({ @@ -102,6 +105,7 @@ export const useConsensusStore = create((set, get) => ({ dissent: null, cost: null, threadId: null, + overview: null, }) ws.connect({ @@ -141,6 +145,7 @@ export const useConsensusStore = create((set, get) => ({ dissent: null, cost: null, threadId: null, + overview: null, }) }, @@ -223,6 +228,19 @@ function handleEvent( break } + case 'challenge_error': { + const found = getRound(state.rounds, state.currentRound) + if (!found) break + const [round, idx] = found + + set({ + rounds: updateRound(state.rounds, idx, { + challenges: [...round.challenges, { model: event.model, content: 'Challenge failed', error: true }], + }), + }) + break + } + case 'commit': { const found = getRound(state.rounds, state.currentRound) if (!found) break @@ -248,6 +266,7 @@ function handleEvent( dissent: event.dissent, cost: event.cost, threadId: event.thread_id ?? null, + overview: event.overview ?? null, }) break } diff --git a/web/src/stores/index.ts b/web/src/stores/index.ts index f5babdb..9cad40e 100644 --- a/web/src/stores/index.ts +++ b/web/src/stores/index.ts @@ -1,3 +1,5 @@ +export { useAuthStore } from './auth' +export type { AuthStatus } from './auth' export { useConsensusStore } from './consensus' export type { ConsensusStatus, ChallengeEntry, RoundData } from './consensus' export { useThreadsStore } from './threads' diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 4db173d..7fddb7a 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/test-setup.ts","./src/three-types.d.ts","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/api/websocket.ts","./src/components/calibration/calibrationdashboard.tsx","./src/components/calibration/index.ts","./src/components/consensus/confidencemeter.tsx","./src/components/consensus/consensuscomplete.tsx","./src/components/consensus/consensuspanel.tsx","./src/components/consensus/costticker.tsx","./src/components/consensus/dissentbanner.tsx","./src/components/consensus/modelbadge.tsx","./src/components/consensus/phasecard.tsx","./src/components/consensus/questioninput.tsx","./src/components/consensus/streamingtext.tsx","./src/components/consensus/index.ts","./src/components/decision-space/decisioncloud.tsx","./src/components/decision-space/decisionspace.tsx","./src/components/decision-space/filterpanel.tsx","./src/components/decision-space/gridfloor.tsx","./src/components/decision-space/scatterfallback.tsx","./src/components/decision-space/scene3d.tsx","./src/components/decision-space/timelineslider.tsx","./src/components/decision-space/index.ts","./src/components/layout/shell.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/layout/index.ts","./src/components/preferences/preferencespanel.tsx","./src/components/preferences/index.ts","./src/components/shared/badge.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/exportmenu.tsx","./src/components/shared/glasspanel.tsx","./src/components/shared/glowbutton.tsx","./src/components/shared/gridoverlay.tsx","./src/components/shared/markdown.tsx","./src/components/shared/pagetransition.tsx","./src/components/shared/particlefield.tsx","./src/components/shared/skeleton.tsx","./src/components/shared/index.ts","./src/components/threads/threadbrowser.tsx","./src/components/threads/threadcard.tsx","./src/components/threads/threaddetail.tsx","./src/components/threads/threadfilters.tsx","./src/components/threads/threadsearch.tsx","./src/components/threads/turncard.tsx","./src/components/threads/index.ts","./src/hooks/index.ts","./src/hooks/usemediaquery.ts","./src/pages/calibrationpage.tsx","./src/pages/consensuspage.tsx","./src/pages/decisionspacepage.tsx","./src/pages/preferencespage.tsx","./src/pages/sharepage.tsx","./src/pages/threaddetailpage.tsx","./src/pages/threadspage.tsx","./src/pages/index.ts","./src/stores/calibration.ts","./src/stores/consensus.ts","./src/stores/decision-space.ts","./src/stores/index.ts","./src/stores/preferences.ts","./src/stores/threads.ts","./src/utils/colors.ts","./src/utils/index.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/test-setup.ts","./src/three-types.d.ts","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/api/websocket.ts","./src/components/calibration/calibrationdashboard.tsx","./src/components/calibration/index.ts","./src/components/consensus/confidencemeter.tsx","./src/components/consensus/consensuscomplete.tsx","./src/components/consensus/consensusnav.tsx","./src/components/consensus/consensuspanel.tsx","./src/components/consensus/costticker.tsx","./src/components/consensus/dissentbanner.tsx","./src/components/consensus/modelbadge.tsx","./src/components/consensus/phasecard.tsx","./src/components/consensus/questioninput.tsx","./src/components/consensus/streamingtext.tsx","./src/components/consensus/index.ts","./src/components/decision-space/decisioncloud.tsx","./src/components/decision-space/decisionspace.tsx","./src/components/decision-space/filterpanel.tsx","./src/components/decision-space/gridfloor.tsx","./src/components/decision-space/scatterfallback.tsx","./src/components/decision-space/scene3d.tsx","./src/components/decision-space/timelineslider.tsx","./src/components/decision-space/index.ts","./src/components/layout/shell.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/layout/index.ts","./src/components/preferences/preferencespanel.tsx","./src/components/preferences/index.ts","./src/components/shared/badge.tsx","./src/components/shared/disclosure.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/exportmenu.tsx","./src/components/shared/glasspanel.tsx","./src/components/shared/glowbutton.tsx","./src/components/shared/gridoverlay.tsx","./src/components/shared/markdown.tsx","./src/components/shared/pagetransition.tsx","./src/components/shared/particlefield.tsx","./src/components/shared/protectedroute.tsx","./src/components/shared/skeleton.tsx","./src/components/shared/index.ts","./src/components/threads/threadbrowser.tsx","./src/components/threads/threadcard.tsx","./src/components/threads/threaddetail.tsx","./src/components/threads/threadfilters.tsx","./src/components/threads/threadnav.tsx","./src/components/threads/threadsearch.tsx","./src/components/threads/turncard.tsx","./src/components/threads/index.ts","./src/hooks/index.ts","./src/hooks/usemediaquery.ts","./src/pages/calibrationpage.tsx","./src/pages/consensuspage.tsx","./src/pages/decisionspacepage.tsx","./src/pages/loginpage.tsx","./src/pages/preferencespage.tsx","./src/pages/resetpasswordpage.tsx","./src/pages/sharepage.tsx","./src/pages/threaddetailpage.tsx","./src/pages/threadspage.tsx","./src/pages/index.ts","./src/stores/auth.ts","./src/stores/calibration.ts","./src/stores/consensus.ts","./src/stores/decision-space.ts","./src/stores/index.ts","./src/stores/preferences.ts","./src/stores/threads.ts","./src/utils/colors.ts","./src/utils/index.ts"],"version":"5.9.3"} \ No newline at end of file