diff --git a/repi.session.sql b/repi.session.sql new file mode 100644 index 0000000..e69de29 diff --git a/repi/api/chat.py b/repi/api/chat.py index 7c19874..2ee4381 100644 --- a/repi/api/chat.py +++ b/repi/api/chat.py @@ -24,12 +24,10 @@ import logging from collections import Counter from datetime import datetime, timedelta, timezone -from typing import List, Literal, Optional from uuid import UUID, uuid4 from fastapi import APIRouter from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field from sqlalchemy import select, text as sa_text from repi.core.container import get_container @@ -44,6 +42,7 @@ from repi.llm.provider import Message from repi.models.filters import RetrievalFilters from repi.models.schema import ChatMessage, Conversation +from repi.api.schemas import ChatFilters, ChatRequest, ChatTurn from repi.retrieval.cluster_view import cluster_chunks from repi.retrieval.timeline_view import build_timeline @@ -52,43 +51,6 @@ router = APIRouter() -# ── Request / response models ───────────────────────────────────────────────── - - -class ChatTurn(BaseModel): - role: Literal["user", "assistant"] - content: str - - -class ChatFilters(BaseModel): - service: Optional[str] = None - time_from: Optional[datetime] = None - time_to: Optional[datetime] = None - entity: Optional[str] = None - - -class ChatRequest(BaseModel): - query: str - history: List[ChatTurn] = [] - filters: Optional[ChatFilters] = None - conversation_id: Optional[UUID] = None - # Followup-bias hint: chunk_ids the previous assistant turn cited. When - # the current query is missing EITHER an explicit service or an explicit - # time window, the chat path fills in just the missing dimension from - # the previous turn's chunks — service via dominant-source check, time - # via a `Settings.FOLLOWUP_BIAS_WINDOW_MINUTES` envelope around their - # timestamps. Soft — never overrides an explicit filter, silently - # ignored if the IDs no longer resolve. - # - # Capped at 50 to bound the indexed-PK fetch and reject malformed - # payloads early. The legitimate caller only ever sends the last - # assistant turn's citations (≤10 in practice). - previous_chunk_ids: List[str] = Field(default_factory=list, max_length=50) - # UX P1: scopes retrieval + known-services resolution to one project. If - # omitted but the conversation has a project, that project applies. - project_id: Optional[UUID] = None - - # ── Module-level constants ──────────────────────────────────────────────────── # Caller-visible window on cited-chunk `text` in the SSE done payload. Locked diff --git a/repi/api/conversations.py b/repi/api/conversations.py index 13548d5..0c78de9 100644 --- a/repi/api/conversations.py +++ b/repi/api/conversations.py @@ -7,51 +7,21 @@ from __future__ import annotations import logging -from typing import List, Literal, Optional +from typing import List from uuid import UUID from fastapi import APIRouter, HTTPException -from pydantic import BaseModel from sqlmodel import select from repi.core.container import get_container from repi.models.schema import ChatMessage, Conversation, Investigation, Project +from repi.api.schemas import ConversationDetail, ConversationSummary, TranscriptTurn logger = logging.getLogger("repi.api.conversations") router = APIRouter() -class ConversationSummary(BaseModel): - id: str - title: Optional[str] - project_id: Optional[str] = None - project_name: Optional[str] = None - created_at: str - updated_at: str - - -class TranscriptTurn(BaseModel): - mode: Literal["chat", "investigate"] - id: str - role: Optional[str] = None # "user" | "assistant" for chat turns - content: str - chunk_ids: List[str] = [] - confidence: Optional[str] = None - status: Optional[str] = None # investigation status (chat turns leave this null) - created_at: str - - -class ConversationDetail(BaseModel): - id: str - title: Optional[str] - project_id: Optional[str] = None - project_name: Optional[str] = None - created_at: str - updated_at: str - turns: List[TranscriptTurn] - - @router.get("/conversations", response_model=List[ConversationSummary]) async def list_conversations(limit: int = 50): container = get_container() diff --git a/repi/api/ingest.py b/repi/api/ingest.py index c765fd4..e68732d 100644 --- a/repi/api/ingest.py +++ b/repi/api/ingest.py @@ -1,21 +1,12 @@ import logging from fastapi import APIRouter, UploadFile, File, Form, Depends -from pydantic import BaseModel from repi.core.container import get_container +from repi.api.schemas import IngestResponse logger = logging.getLogger("repi.api.ingest") router = APIRouter() -class IngestResponse(BaseModel): - service: str - project: str - chunk_count: int - lines_total: int - lines_with_timestamp: int - level_counts: dict[str, int] - message: str - @router.post("/ingest", response_model=IngestResponse) async def ingest( service: str = Form(...), diff --git a/repi/api/investigate.py b/repi/api/investigate.py index 84dd30c..989587f 100644 --- a/repi/api/investigate.py +++ b/repi/api/investigate.py @@ -4,59 +4,23 @@ from typing import Optional, List from fastapi import APIRouter, HTTPException, Depends from fastapi.responses import StreamingResponse -from pydantic import BaseModel from uuid import UUID from datetime import datetime from repi.core.container import get_container from repi.investigation.react_loop import InvestigationStep +from repi.api.schemas import ( + ClarifyRequest, + InvestigateRequest, + InvestigationResponse, + InvestigationStepModel, + SimpleInvestigationResponse, +) logger = logging.getLogger("repi.api.investigate") router = APIRouter() -class InvestigateRequest(BaseModel): - query: str - resume: bool = True - # Optional thread back to a chat surface (A1/A2). If omitted, a new - # conversation row is created and its id returned so the UI can attach - # subsequent /chat turns to the same thread. - conversation_id: Optional[UUID] = None - # UX P1: scopes retrieval + every ReAct tool to one project. If omitted - # but the conversation has a project, the conversation's project applies. - project_id: Optional[UUID] = None - -class InvestigationStepModel(BaseModel): - step_number: int - thought: str - # Legacy preview fields — kept for back-compat with anything that may still - # read them. The list endpoint returns empty steps anyway. - tool_name: Optional[str] = None - tool_args: Optional[dict] = None - observation_preview: Optional[str] = None - # Rich shape the UI uses to render a step identically to the SSE stream. - action: Optional[dict] = None - observation: Optional[dict] = None - kind: Optional[str] = None - -class InvestigationResponse(BaseModel): - id: str - query: str - status: str - answer: Optional[str] = None - created_at: datetime - steps: List[InvestigationStepModel] - pending_question: Optional[str] = None - stats: Optional[dict] = None - -class SimpleInvestigationResponse(BaseModel): - id: str - status: str - conversation_id: Optional[str] = None - -class ClarifyRequest(BaseModel): - reply: str - @router.get("/investigations", response_model=List[InvestigationResponse]) async def list_investigations(limit: int = 20): """List recent investigations.""" diff --git a/repi/api/projects.py b/repi/api/projects.py index c179be8..d27cb16 100644 --- a/repi/api/projects.py +++ b/repi/api/projects.py @@ -13,13 +13,13 @@ from uuid import UUID from fastapi import APIRouter, HTTPException -from pydantic import BaseModel from sqlalchemy import text as sa_text from sqlmodel import select from repi.core.container import get_container from repi.core.dates import DateHandler from repi.models.schema import Project +from repi.api.schemas import ProjectCreate, ProjectRead, ProjectService, ProjectUpdate from repi.retrieval.event_feed import derive_events, fetch_window_aggregates, parse_window logger = logging.getLogger("repi.api.projects") @@ -76,33 +76,6 @@ async def _get_or_create_by_name(session, name: str) -> Project: return row -# ── Models ─────────────────────────────────────────────────────────────────── - -class ProjectCreate(BaseModel): - name: str - settings: dict[str, Any] = {} - - -class ProjectUpdate(BaseModel): - name: Optional[str] = None - settings: Optional[dict[str, Any]] = None - - -class ProjectRead(BaseModel): - id: str - name: str - settings: dict[str, Any] - service_count: int = 0 - created_at: datetime - updated_at: datetime - - -class ProjectService(BaseModel): - name: str - chunk_count: int - last_seen: Optional[datetime] = None - - # ── Endpoints ──────────────────────────────────────────────────────────────── @router.get("/projects", response_model=List[ProjectRead]) @@ -275,9 +248,6 @@ async def project_overview( for r in svc_rows ] - # Suggested actions — derived, never LLM-generated. Top clusters become - # Deep-Research entry points with the service + time range pre-filled so - # the investigation starts grounded instead of from a bare phrase. suggested: list[dict] = [] for c in clusters[:3]: svc_part = f" on {c['services'][0]}" if c["services"] else "" @@ -289,16 +259,6 @@ async def project_overview( f"between {c['first_ts']} and {c['last_ts']}" ), }) - suggested.append({ - "kind": "chat", - "label": f"Summarize the last {window_str}", - "query": f"summarize what happened in the last {window_str}", - }) - suggested.append({ - "kind": "chat", - "label": "Show affected services", - "query": "which services are having problems?", - }) return { "project_id": str(project_id), diff --git a/repi/api/schemas.py b/repi/api/schemas.py new file mode 100644 index 0000000..7d81241 --- /dev/null +++ b/repi/api/schemas.py @@ -0,0 +1,207 @@ +"""Shared API request/response models. + +Pydantic models for the HTTP surface, extracted out of the individual route +modules so they live in one tagged, importable place (mirrors the +``repi/models/`` convention for domain/db models). The route modules import +these back in; FastAPI behaviour, validation and the generated OpenAPI schema +are unchanged by the move. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, List, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +# ── Ingest ──────────────────────────────────────────────────────────────────── + +class IngestResponse(BaseModel): + service: str + project: str + chunk_count: int + lines_total: int + lines_with_timestamp: int + level_counts: dict[str, int] + message: str + + +# ── Watchers ────────────────────────────────────────────────────────────────── + +class WatcherConfigCreate(BaseModel): + service_name: str + watch_path: str + env: str = "production" + enabled: bool = True + project_id: UUID | None = None + + +class WatcherConfigRead(BaseModel): + id: UUID + service_name: str + watch_path: str + env: str + enabled: bool + project_id: UUID | None = None + created_at: datetime + updated_at: datetime + + +class WatcherConfigUpdate(BaseModel): + service_name: str = None + watch_path: str = None + env: str = None + enabled: bool = None + project_id: UUID = None + + +class WatcherStatus(BaseModel): + file_path: str + offset: int + last_seen_at: datetime + updated_at: datetime + + +# ── Projects ────────────────────────────────────────────────────────────────── + +class ProjectCreate(BaseModel): + name: str + settings: dict[str, Any] = {} + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + settings: Optional[dict[str, Any]] = None + + +class ProjectRead(BaseModel): + id: str + name: str + settings: dict[str, Any] + service_count: int = 0 + created_at: datetime + updated_at: datetime + + +class ProjectService(BaseModel): + name: str + chunk_count: int + last_seen: Optional[datetime] = None + + +# ── Conversations ───────────────────────────────────────────────────────────── + +class ConversationSummary(BaseModel): + id: str + title: Optional[str] + project_id: Optional[str] = None + project_name: Optional[str] = None + created_at: str + updated_at: str + + +class TranscriptTurn(BaseModel): + mode: Literal["chat", "investigate"] + id: str + role: Optional[str] = None # "user" | "assistant" for chat turns + content: str + chunk_ids: List[str] = [] + confidence: Optional[str] = None + status: Optional[str] = None # investigation status (chat turns leave this null) + created_at: str + + +class ConversationDetail(BaseModel): + id: str + title: Optional[str] + project_id: Optional[str] = None + project_name: Optional[str] = None + created_at: str + updated_at: str + turns: List[TranscriptTurn] + + +# ── Investigations ──────────────────────────────────────────────────────────── + +class InvestigateRequest(BaseModel): + query: str + resume: bool = True + # Optional thread back to a chat surface (A1/A2). If omitted, a new + # conversation row is created and its id returned so the UI can attach + # subsequent /chat turns to the same thread. + conversation_id: Optional[UUID] = None + # UX P1: scopes retrieval + every ReAct tool to one project. If omitted + # but the conversation has a project, the conversation's project applies. + project_id: Optional[UUID] = None + + +class InvestigationStepModel(BaseModel): + step_number: int + thought: str + # Legacy preview fields — kept for back-compat with anything that may still + # read them. The list endpoint returns empty steps anyway. + tool_name: Optional[str] = None + tool_args: Optional[dict] = None + observation_preview: Optional[str] = None + # Rich shape the UI uses to render a step identically to the SSE stream. + action: Optional[dict] = None + observation: Optional[dict] = None + kind: Optional[str] = None + + +class InvestigationResponse(BaseModel): + id: str + query: str + status: str + answer: Optional[str] = None + created_at: datetime + steps: List[InvestigationStepModel] + pending_question: Optional[str] = None + stats: Optional[dict] = None + + +class SimpleInvestigationResponse(BaseModel): + id: str + status: str + conversation_id: Optional[str] = None + + +class ClarifyRequest(BaseModel): + reply: str + + +# ── Chat ────────────────────────────────────────────────────────────────────── + +class ChatTurn(BaseModel): + role: Literal["user", "assistant"] + content: str + + +class ChatFilters(BaseModel): + service: Optional[str] = None + time_from: Optional[datetime] = None + time_to: Optional[datetime] = None + entity: Optional[str] = None + + +class ChatRequest(BaseModel): + query: str + history: List[ChatTurn] = [] + filters: Optional[ChatFilters] = None + conversation_id: Optional[UUID] = None + # Followup-bias hint: chunk_ids the previous assistant turn cited. When + # the current query is missing EITHER an explicit service or an explicit + # time window, the chat path fills in just the missing dimension from + # the previous turn's chunks — service via dominant-source check, time + # via a `Settings.FOLLOWUP_BIAS_WINDOW_MINUTES` envelope around their + # timestamps. Soft — never overrides an explicit filter, silently + # ignored if the IDs no longer resolve. + # + # Capped at 50 to bound the indexed-PK fetch and reject malformed + # payloads early. The legitimate caller only ever sends the last + # assistant turn's citations (≤10 in practice). + previous_chunk_ids: List[str] = Field(default_factory=list, max_length=50) + # UX P1: scopes retrieval + known-services resolution to one project. If + # omitted but the conversation has a project, that project applies. + project_id: Optional[UUID] = None diff --git a/repi/api/watchers.py b/repi/api/watchers.py index 76d38e8..51f0255 100644 --- a/repi/api/watchers.py +++ b/repi/api/watchers.py @@ -3,46 +3,21 @@ from uuid import UUID, uuid4 from datetime import datetime from fastapi import APIRouter, HTTPException, Depends -from pydantic import BaseModel from sqlmodel import select from repi.core.container import get_container from repi.models.schema import WatcherConfig, WatcherOffset +from repi.api.schemas import ( + WatcherConfigCreate, + WatcherConfigRead, + WatcherConfigUpdate, + WatcherStatus, +) logger = logging.getLogger("repi.api.watchers") router = APIRouter() -class WatcherConfigCreate(BaseModel): - service_name: str - watch_path: str - env: str = "production" - enabled: bool = True - project_id: UUID | None = None - -class WatcherConfigRead(BaseModel): - id: UUID - service_name: str - watch_path: str - env: str - enabled: bool - project_id: UUID | None = None - created_at: datetime - updated_at: datetime - -class WatcherConfigUpdate(BaseModel): - service_name: str = None - watch_path: str = None - env: str = None - enabled: bool = None - project_id: UUID = None - -class WatcherStatus(BaseModel): - file_path: str - offset: int - last_seen_at: datetime - updated_at: datetime - @router.post("/watchers", response_model=WatcherConfigRead) async def create_watcher(config: WatcherConfigCreate): container = get_container() diff --git a/web/app/globals.css b/web/app/globals.css index ee2b7a3..bccb224 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -7,9 +7,9 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-jakarta); - --font-mono: var(--font-mono); - --font-heading: var(--font-jakarta); + --font-sans: var(--font-geist); + --font-mono: var(--font-geist-mono); + --font-heading: var(--font-geist); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -127,4 +127,104 @@ html { @apply font-sans; } +} + +/* Markdown rendering for chat prose / compiled answers / step thoughts. + Tailwind's preflight strips element margins, so ReactMarkdown output needs + its own typographic spacing. Inline `code` gets a Notion-like warm orange + hue; fenced code blocks keep the neutral mono treatment. */ +@layer components { + .md-content { + line-height: 1.6; + word-break: break-word; + } + .md-content > :first-child { + margin-top: 0; + } + .md-content > :last-child { + margin-bottom: 0; + } + .md-content p { + margin: 0.5em 0; + } + .md-content ul, + .md-content ol { + margin: 0.5em 0; + padding-left: 1.25em; + } + .md-content ul { + list-style: disc; + } + .md-content ol { + list-style: decimal; + } + .md-content li { + margin: 0.15em 0; + } + .md-content li > ul, + .md-content li > ol { + margin: 0.15em 0; + } + .md-content h1, + .md-content h2, + .md-content h3, + .md-content h4 { + font-weight: 600; + line-height: 1.3; + margin: 0.75em 0 0.35em; + } + .md-content h1 { + font-size: 1.25em; + } + .md-content h2 { + font-size: 1.15em; + } + .md-content h3 { + font-size: 1.05em; + } + .md-content a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; + } + .md-content strong { + font-weight: 600; + } + .md-content em { + font-style: italic; + } + .md-content blockquote { + border-left: 2px solid var(--border); + padding-left: 0.75em; + margin: 0.5em 0; + color: var(--muted-foreground); + } + .md-content pre { + margin: 0.5em 0; + padding: 0.75em; + border-radius: 0.5rem; + background: var(--muted); + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.85em; + } + .md-content pre code { + background: none; + color: inherit; + padding: 0; + } + /* Notion-like warm orange inline code (not fenced blocks). */ + .md-content :not(pre) > code { + font-family: var(--font-mono); + font-size: 0.85em; + color: oklch(0.62 0.17 45); + background: oklch(0.62 0.17 45 / 0.1); + padding: 0.12em 0.34em; + border-radius: 0.32rem; + white-space: break-spaces; + } + .dark .md-content :not(pre) > code { + color: oklch(0.78 0.15 55); + background: oklch(0.78 0.15 55 / 0.14); + } } \ No newline at end of file diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 9352fe2..c5a66fe 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,13 +1,21 @@ import type { Metadata } from "next"; -import { Plus_Jakarta_Sans } from "next/font/google"; +import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { Navbar } from "@/components/navbar"; -const jakarta = Plus_Jakarta_Sans({ +// Mellow, developer-native pairing: Geist for body/headings, Geist Mono for +// code/logs. Geist Mono finally backs the `--font-mono` slot that globals.css +// referenced but nothing previously loaded. +const geist = Geist({ subsets: ["latin"], - variable: "--font-jakarta", + variable: "--font-geist", +}); + +const geistMono = Geist_Mono({ + subsets: ["latin"], + variable: "--font-geist-mono", }); export const metadata: Metadata = { @@ -23,7 +31,7 @@ export default function RootLayout({ return ( . -// `finalAnswer` is populated when the investigation's SSE `done` event fires — -// kept on the parent's state so the next /chat turn can include it as history -// context (lite contextual chat: see `buildChatHistory`). -type Turn = - | ({ mode: "chat" } & ChatMessageProps) - | { - mode: "investigate" - id: string - investigationId: string - query: string - finalAnswer?: string - } - // Token-budget proxy: number of recent turns to send to /chat as `history`. // 6 covers a typical Q&A → followup → followup flow without bloating the prompt. // No compaction, no summarisation — when it falls off it falls off. The @@ -80,7 +65,16 @@ export default function HomePage() { const [turns, setTurns] = useState([]) const [busy, setBusy] = useState(false) const [sidebarRefresh, setSidebarRefresh] = useState(0) + // Conversation id whose investigation is streaming this session — drives the + // sidebar's per-row loading spinner. Session-only: cleared when the stream + // settles or the user switches conversations (not persisted across reload). + const [activeInvestigatingConvId, setActiveInvestigatingConvId] = useState(null) const scrollRef = useRef(null) + const contentRef = useRef(null) + // Whether the view should keep itself pinned to the bottom as new content + // (streaming investigation steps / chat deltas) grows. Flips to false when + // the user scrolls up to read, so we don't yank them back down. + const stickToBottom = useRef(true) // Restore the sticky toggle from localStorage on mount. useEffect(() => { @@ -93,16 +87,43 @@ export default function HomePage() { try { localStorage.setItem(DR_KEY, deepResearch ? "1" : "0") } catch {} }, [deepResearch]) - // Auto-scroll to bottom on new content. + // New turn (the user just sent something, or a transcript loaded) → snap to + // the bottom and re-arm bottom-sticking. useEffect(() => { + stickToBottom.current = true if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } }, [turns]) + // Follow streaming content (investigation steps / chat deltas) that grows + // WITHOUT changing `turns`. A ResizeObserver on the content wrapper keeps the + // view pinned to the bottom, but only while the user is already there. + useEffect(() => { + const el = scrollRef.current + const content = contentRef.current + if (!el || !content) return + const ro = new ResizeObserver(() => { + if (stickToBottom.current) el.scrollTop = el.scrollHeight + }) + ro.observe(content) + return () => ro.disconnect() + }, []) + + // Track whether the user is parked near the bottom; drives `stickToBottom`. + const onScroll = () => { + const el = scrollRef.current + if (!el) return + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight + stickToBottom.current = distanceFromBottom < 80 + } + // Load an existing conversation's transcript from the API. const loadConversation = useCallback(async (id: string | null) => { setConversationId(id) + // Switching conversations drops the live stream we were following, so any + // session-only "investigating" spinner no longer applies. + setActiveInvestigatingConvId(null) if (!id) { // New conversation → back to project selection (the picker auto-skips // itself when exactly one project exists). @@ -152,9 +173,13 @@ export default function HomePage() { // Toggle ON → kick off a real investigation, embed its SSE stream. try { const res = await api.investigations.create(query, conversationId ?? undefined, project?.id) + const convId = res.conversation_id ?? conversationId if (!conversationId && res.conversation_id) { setConversationId(res.conversation_id) } + // Mark this conversation as investigating so the sidebar shows a spinner + // on its row until the stream settles (see InvestigateTurnView.onSettled). + if (convId) setActiveInvestigatingConvId(convId) setTurns((prev) => [ ...prev, { @@ -300,13 +325,13 @@ export default function HomePage() { // New chat, no project yet → step 1 of the flow: pick (or create) a project. if (!conversationId && !project) { return ( -
+
-
+
setProject({ id: p.id, name: p.name })} />
@@ -314,14 +339,16 @@ export default function HomePage() { } return ( -
+
-
-
+
+
+
{empty ? ( // Timeline-first landing: no empty chat screen. The overview // answers "what happened recently?" before the user types. @@ -353,6 +380,7 @@ export default function HomePage() { key={t.id} investigationId={t.investigationId} alreadyHoisted={!!t.finalAnswer} + onSettled={() => setActiveInvestigatingConvId(null)} onComplete={(finalAnswer) => { setTurns((prev) => { const idx = prev.findIndex( @@ -370,6 +398,7 @@ export default function HomePage() { ), ) )} +
void + onSettled?: () => void } -function InvestigateTurnView({ investigationId, alreadyHoisted, onComplete }: InvestigateTurnViewProps) { +function InvestigateTurnView({ investigationId, alreadyHoisted, onComplete, onSettled }: InvestigateTurnViewProps) { const streamUrl = `${API_BASE}/investigations/${investigationId}/stream` const { steps, answer, error, done, clarificationQuestion, awaitingClarification, phase } = useSSE(streamUrl) @@ -402,6 +432,12 @@ function InvestigateTurnView({ investigationId, alreadyHoisted, onComplete }: In if (done && answer && !alreadyHoisted) onComplete(answer) }, [done, answer, alreadyHoisted, onComplete]) + // Stream reached a terminal state (answered, errored, or otherwise done) → + // let the parent clear the sidebar "investigating" spinner. + useEffect(() => { + if (done || error) onSettled?.() + }, [done, error, onSettled]) + // Renders as the assistant-side response of a chat turn. The user-side // bubble is owned by the parent (the optimistic push in handleSend) — this // component is purely "what the assistant did to answer." diff --git a/web/components/chat/ChatMessage.tsx b/web/components/chat/ChatMessage.tsx index 02937bf..0b7a677 100644 --- a/web/components/chat/ChatMessage.tsx +++ b/web/components/chat/ChatMessage.tsx @@ -1,31 +1,18 @@ "use client" import { useRef, useState } from "react" +import ReactMarkdown from "react-markdown" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { AlertTriangle, Clock, Layers, Microscope, Sparkles, User } from "lucide-react" -import { EventClusters, type Cluster } from "@/components/chat/EventClusters" -import { Timeline, type TimelineEntry } from "@/components/chat/Timeline" -import { CitedChunks, type CitedChunk } from "@/components/chat/CitedChunks" +import { EventClusters } from "@/components/chat/EventClusters" +import { Timeline } from "@/components/chat/Timeline" +import { CitedChunks } from "@/components/chat/CitedChunks" +import type { ChatMessageProps } from "@/lib/types" -export type ChatMessageProps = { - role: "user" | "assistant" - content: string - chunkIds?: string[] - confidence?: "low" | "medium" | "high" | null - isClarification?: boolean - streaming?: boolean - clusters?: Cluster[] - timeline?: TimelineEntry[] - citedChunks?: CitedChunk[] - query?: string - onInvestigateDeeper?: (query: string) => void -} +export type { ChatMessageProps } -// Strip raw chunk citations the LLM may still inline despite the system -// prompt asking it not to. Catches [chunk:abc...] and bare hex/uuid runs in -// brackets so the user sees clean prose. function cleanContent(raw: string): string { if (!raw) return raw return raw @@ -88,9 +75,9 @@ export function ChatMessageView({
@@ -100,7 +87,19 @@ export function ChatMessageView({ Need a bit more info
)} - {displayed || (streaming ? : "")} + {isUser ? ( + displayed + ) : displayed ? ( + // Assistant prose is markdown — render it. The `md-content` class + // carries the typographic spacing + Notion-style orange backticks. +
+ {displayed} +
+ ) : streaming ? ( + + ) : ( + "" + )}
{!isUser && confidence && ( diff --git a/web/components/chat/CitedChunks.tsx b/web/components/chat/CitedChunks.tsx index 25333ad..6edde85 100644 --- a/web/components/chat/CitedChunks.tsx +++ b/web/components/chat/CitedChunks.tsx @@ -6,13 +6,8 @@ import { ChevronDown, ChevronRight, FileText } from "lucide-react" import { cn } from "@/lib/utils" import { levelTone } from "@/lib/log-levels" -export type CitedChunk = { - chunk_id: string - service: string | null - level: string | null - timestamp: string | null - text: string -} +import type { CitedChunk } from "@/lib/types" +export type { CitedChunk } function shortTs(iso: string | null): string { if (!iso) return "" diff --git a/web/components/chat/CompiledAnswer.tsx b/web/components/chat/CompiledAnswer.tsx index 1d2c027..d1afb81 100644 --- a/web/components/chat/CompiledAnswer.tsx +++ b/web/components/chat/CompiledAnswer.tsx @@ -3,37 +3,7 @@ import ReactMarkdown from "react-markdown" import { Badge } from "@/components/ui/badge" import { AlertTriangle, ArrowDown, Target, Ban, HelpCircle } from "lucide-react" - -// Shape of the compiled InvestigationAnswer (see repi/investigation/schema.py). -// Everything is optional here because the JSON comes off the wire and we render -// defensively — a missing section is simply skipped. -interface TriggerEvent { - chunk_id?: string - service?: string - timestamp?: string - log_line?: string -} -interface PropagationHop { - service?: string - chunk_id?: string - ts?: string - what?: string -} -interface RuledOut { - hypothesis?: string - why_ruled_out?: string -} -interface InvestigationAnswer { - incident_window?: { start?: string; end?: string } - affected_services?: string[] - trigger_event?: TriggerEvent - propagation_chain?: PropagationHop[] - root_cause?: string - ruled_out_hypotheses?: RuledOut[] - assumptions?: string[] - confidence?: string - gaps?: string[] -} +import type { InvestigationAnswer } from "@/lib/types" // Strip the model's inline chunk references from prose — same cleanup the old // plain-text card did. @@ -89,11 +59,12 @@ function Section({ export function CompiledAnswer({ answer }: { answer: string }) { const parsed = tryParse(answer) - // Fallback: plain-text answer (clarification text or legacy prose). + // Fallback: plain-text answer (clarification text or legacy prose). Rendered + // as markdown for consistency with the rest of the chat surface. if (!parsed) { return ( -
- {stripChunkRefs(answer)} +
+ {stripChunkRefs(answer)}
) } @@ -132,7 +103,7 @@ export function CompiledAnswer({ answer }: { answer: string }) { {/* Root cause — the headline */} {root_cause && (
} title="Root cause"> -
+
{stripChunkRefs(root_cause)}
diff --git a/web/components/chat/EventClusters.tsx b/web/components/chat/EventClusters.tsx index d7cc00f..c12ea50 100644 --- a/web/components/chat/EventClusters.tsx +++ b/web/components/chat/EventClusters.tsx @@ -5,13 +5,8 @@ import { Badge } from "@/components/ui/badge" import { ChevronDown, ChevronRight, Layers } from "lucide-react" import { cn } from "@/lib/utils" -export type Cluster = { - signature: string - count: number - services: string[] - first_ts: string | null - last_ts: string | null -} +import type { Cluster } from "@/lib/types" +export type { Cluster } function formatRange(first: string | null, last: string | null): string { if (!first && !last) return "" diff --git a/web/components/chat/ThinkingIndicator.tsx b/web/components/chat/ThinkingIndicator.tsx index 157f3dd..1dab4c7 100644 --- a/web/components/chat/ThinkingIndicator.tsx +++ b/web/components/chat/ThinkingIndicator.tsx @@ -24,6 +24,10 @@ const THINKING_WORDS = [ "Weighing evidence", "Reading log clusters", "Forming hypotheses", + "Drinking water", + "Taking a deep breath", + "Crashing out", + "Praying to the AI gods" ] function contextLabel(phase: InvestigationPhase | null, lastStep?: Step): string | null { diff --git a/web/components/chat/Timeline.tsx b/web/components/chat/Timeline.tsx index eb5505d..0738a44 100644 --- a/web/components/chat/Timeline.tsx +++ b/web/components/chat/Timeline.tsx @@ -6,14 +6,8 @@ import { ChevronDown, ChevronRight, Clock } from "lucide-react" import { cn } from "@/lib/utils" import { levelTone } from "@/lib/log-levels" -export type TimelineEntry = { - service: string | null - level: string | null - signature: string - first_ts: string - last_ts: string - repeat_count: number -} +import type { TimelineEntry } from "@/lib/types" +export type { TimelineEntry } function formatTs(iso: string): string { if (!iso) return "" diff --git a/web/components/conversations/ConversationSidebar.tsx b/web/components/conversations/ConversationSidebar.tsx index 1f6be29..692a873 100644 --- a/web/components/conversations/ConversationSidebar.tsx +++ b/web/components/conversations/ConversationSidebar.tsx @@ -5,22 +5,19 @@ import { Button } from "@/components/ui/button" import { MessageSquare, Plus } from "lucide-react" import { api } from "@/lib/api" import { cn } from "@/lib/utils" - -type ConversationSummary = { - id: string - title: string | null - project_name?: string | null - created_at: string - updated_at: string -} +import { Spinner } from "@/components/ui/spinner" +import type { ConversationSummary } from "@/lib/types" interface ConversationSidebarProps { activeId: string | null onSelect: (id: string | null) => void refreshKey?: number // bump to force a re-fetch (e.g. after sending a turn) + // Conversation id with a live investigation this session → show a spinner on + // its row in place of the message icon. + activeInvestigatingId?: string | null } -export function ConversationSidebar({ activeId, onSelect, refreshKey }: ConversationSidebarProps) { +export function ConversationSidebar({ activeId, onSelect, refreshKey, activeInvestigatingId }: ConversationSidebarProps) { const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -67,7 +64,11 @@ export function ConversationSidebar({ activeId, onSelect, refreshKey }: Conversa activeId === c.id && "bg-muted font-medium", )} > - + {activeInvestigatingId === c.id ? ( + + ) : ( + + )} {c.title || "Untitled"} {c.project_name && ( diff --git a/web/components/investigation-step.tsx b/web/components/investigation-step.tsx index 7fffae1..f614aba 100644 --- a/web/components/investigation-step.tsx +++ b/web/components/investigation-step.tsx @@ -82,7 +82,7 @@ export function InvestigationStepCard({ step }: { step: Step }) { {/* Thought */}
-
+
{step.thought}
diff --git a/web/lib/sse.ts b/web/lib/sse.ts index 09970ab..fcdbdf1 100644 --- a/web/lib/sse.ts +++ b/web/lib/sse.ts @@ -1,32 +1,14 @@ "use client" import { useState, useEffect, useCallback, useRef } from "react" +import type { + InvestigationPhase, + InvestigationStats, + Step, + StepKind, +} from "@/lib/types" -export type StepKind = null | "reflection" | "signal" | "compile" - -export interface Step { - step_number: number - thought: string - action?: { - tool: string - args: any - } - observation?: any - kind?: StepKind -} - -export type InvestigationPhase = "gathering" | "compiling" | "done" - -export interface InvestigationStats { - iterations_used?: number - reflections_used?: number - chunks_gathered?: number - tools_called?: string[] - compile_source?: string - compile_attempts?: number - floor_adjustments?: string[] - gathering_exit_reason?: string -} +export type { InvestigationPhase, InvestigationStats, Step, StepKind } export function useSSE(url: string | null) { const [steps, setSteps] = useState([]) diff --git a/web/lib/types.ts b/web/lib/types.ts new file mode 100644 index 0000000..07acccc --- /dev/null +++ b/web/lib/types.ts @@ -0,0 +1,132 @@ +// Central type surface for the web app. These declarations were previously +// inlined across component and lib modules; collecting them here gives a single +// import target (`@/lib/types`) for components and tests. The original modules +// re-export from here for backward compatibility, so existing import paths keep +// working. Pure type declarations only — no runtime code. + +// ── Retrieval / evidence views ──────────────────────────────────────────────── + +export type Cluster = { + signature: string + count: number + services: string[] + first_ts: string | null + last_ts: string | null +} + +export type TimelineEntry = { + service: string | null + level: string | null + signature: string + first_ts: string + last_ts: string + repeat_count: number +} + +export type CitedChunk = { + chunk_id: string + service: string | null + level: string | null + timestamp: string | null + text: string +} + +// ── Investigation SSE stream ────────────────────────────────────────────────── + +export type StepKind = null | "reflection" | "signal" | "compile" + +export interface Step { + step_number: number + thought: string + action?: { + tool: string + args: any + } + observation?: any + kind?: StepKind +} + +export type InvestigationPhase = "gathering" | "compiling" | "done" + +export interface InvestigationStats { + iterations_used?: number + reflections_used?: number + chunks_gathered?: number + tools_called?: string[] + compile_source?: string + compile_attempts?: number + floor_adjustments?: string[] + gathering_exit_reason?: string +} + +// ── Compiled investigation answer (mirrors repi/investigation/schema.py) ─────── +// Everything is optional — the JSON comes off the wire and is rendered +// defensively; a missing section is simply skipped. + +export interface TriggerEvent { + chunk_id?: string + service?: string + timestamp?: string + log_line?: string +} + +export interface PropagationHop { + service?: string + chunk_id?: string + ts?: string + what?: string +} + +export interface RuledOut { + hypothesis?: string + why_ruled_out?: string +} + +export interface InvestigationAnswer { + incident_window?: { start?: string; end?: string } + affected_services?: string[] + trigger_event?: TriggerEvent + propagation_chain?: PropagationHop[] + root_cause?: string + ruled_out_hypotheses?: RuledOut[] + assumptions?: string[] + confidence?: string + gaps?: string[] +} + +// ── Chat / conversation ─────────────────────────────────────────────────────── + +export type ChatMessageProps = { + role: "user" | "assistant" + content: string + chunkIds?: string[] + confidence?: "low" | "medium" | "high" | null + isClarification?: boolean + streaming?: boolean + clusters?: Cluster[] + timeline?: TimelineEntry[] + citedChunks?: CitedChunk[] + query?: string + onInvestigateDeeper?: (query: string) => void +} + +// Local transcript model. `mode` discriminates chat vs investigate so the page +// can render the right component inline. Investigation turns carry an +// investigationId; `finalAnswer` is populated when the SSE `done` event fires. +export type Turn = + | ({ mode: "chat" } & ChatMessageProps) + | { + mode: "investigate" + id: string + investigationId: string + query: string + finalAnswer?: string + } + +export type ConversationSummary = { + id: string + title: string | null + project_name?: string | null + created_at: string + updated_at: string +}