diff --git a/TEACHING_ROADMAP_IMPLEMENTATION.md b/TEACHING_ROADMAP_IMPLEMENTATION.md deleted file mode 100644 index 026e0e9..0000000 --- a/TEACHING_ROADMAP_IMPLEMENTATION.md +++ /dev/null @@ -1,22 +0,0 @@ -# Teaching Roadmap Implementation Log - -## Goal -Evolve the roadmap feature into a teaching-oriented learning path for beginners. - -## Plan -1. **Backend Updates** - * [x] Update `TimelineStage` model in `app/models/roadmap.py` (added `prerequisites`, `checkpoints`). - * [x] Update `GeminiRoadmapGenerator` in `app/services/ai/gemini.py`. - * [x] Implement multi-step pipeline (Plan -> Expand). - * [x] Implement time-based commit clustering. - * [x] Update prompts for teaching focus. - * [x] Linting & Formatting. - -2. **Frontend Updates** - * [ ] Update types in `commitly-frontend`. - * [ ] Update UI components to render goals, structured tasks, code examples, etc. - -## Progress -- [x] Backend models updated. -- [x] Gemini service refactored with multi-step pipeline. -- [x] Linting checks. diff --git a/UI_UX_TWEAKS.md b/UI_UX_TWEAKS.md deleted file mode 100644 index fc198af..0000000 --- a/UI_UX_TWEAKS.md +++ /dev/null @@ -1,38 +0,0 @@ -# UI/UX Tweaks for Timeline - -## Overview -Implemented UI/UX improvements for the roadmap timeline view to enhance readability, visual hierarchy, and user guidance. - -## Changes - -### 1. Navigation & Structure -- **Sticky Navigation Pills**: Added a sticky, horizontally scrollable list of stage buttons above the timeline for quick navigation between stages. -- **Increased Spacing**: Increased the vertical gap between timeline stages (`gap-y-16`) to reduce visual clutter and separate "episodes". - -### 2. Timeline Card Visual Hierarchy -- **Header**: - - Added explicit "Stage X · Category · Difficulty" meta-line. - - Improved title and status badge alignment. -- **Goals**: - - Moved to the top of the content. - - Styled with a small uppercase heading and a separator. -- **Tasks**: - - Refactored into individual "subcards" with a clearer border and background. - - Steps are bulleted lists within the subcard. - - Files and commands are clearly distinguished. -- **Code Examples**: - - Made collapsible by default to prevent long snippets from dominating the view. - - Visible file name and language badge in the collapsed state. -- **Resources**: - - Moved to the bottom of the content. - - Styled as pill-shaped links. - -### 3. Call to Action (CTA) -- **Dynamic Buttons**: - - "Start this stage" for not-started stages (when signed in). - - "Continue" for in-progress stages. - - "Review" for completed stages. - - "Details" for signed-out users. - -## Files Modified -- `commitly-frontend/app/repo/[repoId]/timeline/page.tsx` diff --git a/commitly-backend/alembic/versions/20241124_add_guide_chat_sessions.py b/commitly-backend/alembic/versions/20241124_add_guide_chat_sessions.py new file mode 100644 index 0000000..6a89f0c --- /dev/null +++ b/commitly-backend/alembic/versions/20241124_add_guide_chat_sessions.py @@ -0,0 +1,60 @@ +"""add guide chat sessions table + +Revision ID: 20241124_add_guide_chat_sessions +Revises: aae1346a34e5 +Create Date: 2025-11-24 19:20:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "20241124_add_guide_chat_sessions" +down_revision = "aae1346a34e5" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "guide_chat_sessions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.String(length=255), nullable=False), + sa.Column("repo_full_name", sa.String(length=255), nullable=False), + sa.Column("stage_id", sa.String(length=255), nullable=True), + sa.Column("messages", sa.JSON(), nullable=False, server_default="[]"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + onupdate=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint( + "user_id", + "repo_full_name", + "stage_id", + name="uq_guide_chat_user_repo_stage", + ), + ) + op.create_index("ix_guide_chat_sessions_user", "guide_chat_sessions", ["user_id"]) + op.create_index( + "ix_guide_chat_sessions_repo", "guide_chat_sessions", ["repo_full_name"] + ) + op.create_index("ix_guide_chat_sessions_stage", "guide_chat_sessions", ["stage_id"]) + + +def downgrade(): + op.drop_index("ix_guide_chat_sessions_stage", table_name="guide_chat_sessions") + op.drop_index("ix_guide_chat_sessions_repo", table_name="guide_chat_sessions") + op.drop_index("ix_guide_chat_sessions_user", table_name="guide_chat_sessions") + op.drop_table("guide_chat_sessions") diff --git a/commitly-backend/app/api/roadmap.py b/commitly-backend/app/api/roadmap.py index 89abcba..d927ad3 100644 --- a/commitly-backend/app/api/roadmap.py +++ b/commitly-backend/app/api/roadmap.py @@ -12,14 +12,16 @@ from app.core.database import get_db from app.models.roadmap import ( CatalogPage, + ChatHistoryResponse, ChatRequest, - ChatResponse, RatingRequest, RatingResponse, RoadmapRequest, RoadmapResponse, + SaveChatRequest, UserRepoStateResponse, ) +from app.models.roadmap import GuideChatSession from app.services.ai.chat import GeminiChatService from app.services.roadmap_service import RoadmapService, build_roadmap_service @@ -41,6 +43,15 @@ def get_user_id(claims: ClerkClaims) -> str: return user_id +def get_chat_session( + session: Session, user_id: str, repo_full_name: str, stage_id: str | None +) -> GuideChatSession | None: + query = session.query(GuideChatSession).filter_by( + user_id=user_id, repo_full_name=repo_full_name, stage_id=stage_id + ) + return query.first() + + @router.get("/catalog", response_model=CatalogPage) async def list_roadmaps( page: int = Query(1, ge=1, description="Page number"), @@ -251,25 +262,112 @@ async def record_roadmap_view( return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.post("/chat", response_model=ChatResponse) +@router.post("/chat") async def chat_with_guide( payload: ChatRequest, session: Session = Depends(get_db), - current_user: ClerkClaims = Depends(require_clerk_auth), -) -> ChatResponse: + # current_user: ClerkClaims = Depends(require_clerk_auth), +) -> StreamingResponse: """ Chat with the AI guide about the repository or a specific stage. """ + if not settings.gemini_api_key: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Gemini API key not configured.", + ) + chat_service = GeminiChatService( session=session, api_key=settings.gemini_api_key, model=settings.gemini_model, ) - response = await chat_service.chat( - repo_full_name=payload.repo_full_name, - message=payload.message, - stage_id=payload.stage_id, + # If messages are provided (from useChat), use them. + # Otherwise, construct a single message list from payload.message. + messages = payload.messages + if not messages: + if not payload.message: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Either 'messages' or 'message' must be provided.", + ) + messages = [{"role": "user", "content": payload.message}] + + return StreamingResponse( + chat_service.chat_stream( + repo_full_name=payload.repo_full_name, + messages=messages, + stage_id=payload.stage_id, + ), + media_type="text/plain", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Vercel-AI-Data-Stream": "v1", + }, ) - return ChatResponse(response=response) + +@router.get("/chat/history", response_model=ChatHistoryResponse) +async def get_chat_history( + repo_full_name: str, + stage_id: Optional[str] = None, + session: Session = Depends(get_db), + current_user: ClerkClaims = Depends(require_clerk_auth), +): + user_id = get_user_id(current_user) + try: + record = get_chat_session(session, user_id, repo_full_name, stage_id) + if not record: + return ChatHistoryResponse( + repo_full_name=repo_full_name, stage_id=stage_id, messages=[] + ) + return ChatHistoryResponse( + repo_full_name=record.repo_full_name, + stage_id=record.stage_id, + messages=record.messages or [], + ) + except Exception as exc: # pragma: no cover - defensive fallback + # If the table doesn't exist yet (migration pending), return empty history + import logging + + logging.getLogger(__name__).warning( + "chat history fetch failed: %s", exc, exc_info=True + ) + return ChatHistoryResponse( + repo_full_name=repo_full_name, stage_id=stage_id, messages=[] + ) + + +@router.post("/chat/history", status_code=status.HTTP_204_NO_CONTENT) +async def save_chat_history( + payload: SaveChatRequest, + session: Session = Depends(get_db), + current_user: ClerkClaims = Depends(require_clerk_auth), +): + user_id = get_user_id(current_user) + try: + record = get_chat_session( + session, user_id, payload.repo_full_name, payload.stage_id + ) + + if record: + record.messages = payload.messages + else: + record = GuideChatSession( + user_id=user_id, + repo_full_name=payload.repo_full_name, + stage_id=payload.stage_id, + messages=payload.messages, + ) + session.add(record) + + session.commit() + except Exception as exc: # pragma: no cover + import logging + + logging.getLogger(__name__).warning( + "chat history save failed: %s", exc, exc_info=True + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/commitly-backend/app/core/config.py b/commitly-backend/app/core/config.py index 808b6c8..9a682b2 100644 --- a/commitly-backend/app/core/config.py +++ b/commitly-backend/app/core/config.py @@ -1,4 +1,5 @@ from functools import lru_cache +import json from typing import Any, List, Optional, Union from pydantic import Field, HttpUrl, field_validator @@ -44,7 +45,7 @@ class Settings(BaseSettings): ) # GitHub ingestion - github_api_base: HttpUrl = Field( + github_api_base: HttpUrl = Field( # type: ignore "https://api.github.com", validation_alias="GITHUB_API_BASE" ) github_token: Optional[str] = Field(default=None, validation_alias="GITHUB_TOKEN") @@ -91,8 +92,6 @@ def _coerce_list(value: Any) -> List[str]: return [] if cleaned.startswith("["): try: - import json - data = json.loads(cleaned) if isinstance(data, list): return [ @@ -136,7 +135,7 @@ def parse_authorized_parties(cls, value: Any) -> List[str]: @lru_cache def get_settings() -> Settings: """Return a cached instance of the application settings.""" - return Settings() + return Settings() # type: ignore settings = get_settings() diff --git a/commitly-backend/app/models/roadmap.py b/commitly-backend/app/models/roadmap.py index d6197b0..f7a2937 100644 --- a/commitly-backend/app/models/roadmap.py +++ b/commitly-backend/app/models/roadmap.py @@ -130,6 +130,37 @@ class RoadmapRating(Base): ) +class GuideChatSession(Base): + """Stores the latest guide chat history per user/repo/stage.""" + + __tablename__ = "guide_chat_sessions" + __table_args__ = ( + UniqueConstraint( + "user_id", + "repo_full_name", + "stage_id", + name="uq_guide_chat_user_repo_stage", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + repo_full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + stage_id: Mapped[Optional[str]] = mapped_column( + String(255), nullable=True, index=True + ) + messages: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + class RoadmapViewTracker(Base): """Tracks views to prevent spam and implement anti-spam logic.""" @@ -304,10 +335,23 @@ class RatingResponse(BaseModel): class ChatRequest(BaseModel): - message: str + message: Optional[str] = None repo_full_name: str stage_id: Optional[str] = None + messages: Optional[List[dict]] = None # For full chat history context class ChatResponse(BaseModel): response: str + + +class SaveChatRequest(BaseModel): + repo_full_name: str + stage_id: Optional[str] = None + messages: List[dict] + + +class ChatHistoryResponse(BaseModel): + repo_full_name: str + stage_id: Optional[str] = None + messages: List[dict] diff --git a/commitly-backend/app/services/ai/chat.py b/commitly-backend/app/services/ai/chat.py index 7dba79c..b53fde4 100644 --- a/commitly-backend/app/services/ai/chat.py +++ b/commitly-backend/app/services/ai/chat.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging from typing import Optional @@ -14,7 +15,8 @@ MAX_OUTPUT_TOKENS = 2048 SYSTEM_PROMPT_TEMPLATE = """ -You are Commitly, an expert engineering mentor. You are guiding a developer who is rebuilding the repository "{repo_name}". +You are Commitly, an expert engineering mentor. You are guiding a developer who is +rebuilding the repository "{repo_name}". Context: {context} @@ -25,7 +27,8 @@ - Be helpful, encouraging, and technical. - If the context contains code snippets, reference them. - If the user asks about a specific task in the stage, guide them through it. -- If the answer is not in the context, use your general programming knowledge but mention that it's not explicitly in the provided commit history. +- If the answer is not in the context, use your general programming knowledge + but mention that it's not explicitly in the provided commit history. """ @@ -33,17 +36,15 @@ class GeminiChatService: def __init__(self, session: Session, api_key: str, model: str) -> None: self._session = session self._api_key = api_key + self._model = model self._endpoint = ( f"https://generativelanguage.googleapis.com/v1beta/models/" f"{model}:generateContent" ) - async def chat( - self, - repo_full_name: str, - message: str, - stage_id: Optional[str] = None, - ) -> str: + def _build_context( + self, repo_full_name: str, stage_id: Optional[str] = None + ) -> str | None: # 1. Fetch Roadmap roadmap = ( self._session.query(GeneratedRoadmap) @@ -51,7 +52,7 @@ async def chat( .first() ) if not roadmap: - return "I don't have a roadmap for this repository yet. Please generate one first." + return None context = "" @@ -88,6 +89,21 @@ async def chat( for stage in roadmap.timeline: context += f"- {stage['title']}: {stage['summary']}\n" + return context + + async def chat( + self, + repo_full_name: str, + message: str, + stage_id: Optional[str] = None, + ) -> str: + context = self._build_context(repo_full_name, stage_id) + if not context: + return ( + "I don't have a roadmap for this repository yet." + " Please generate one first." + ) + # 2. Call Gemini prompt = SYSTEM_PROMPT_TEMPLATE.format( repo_name=repo_full_name, context=context, user_query=message @@ -95,12 +111,155 @@ async def chat( return await self._call_gemini(prompt) + async def chat_stream( + self, + repo_full_name: str, + messages: list[dict], + stage_id: Optional[str] = None, + ): + context = self._build_context(repo_full_name, stage_id) + if not context: + yield self._format_protocol_text( + "I don't have a roadmap for this repository yet." + " Please generate one first." + ) + return + + # Use the last message from the user + user_query = messages[-1]["content"] if messages else "" + prompt = SYSTEM_PROMPT_TEMPLATE.format( + repo_name=repo_full_name, context=context, user_query=user_query + ) + + payload = { + "contents": [{"role": "user", "parts": [{"text": prompt}]}], + "generation_config": { + "temperature": 0.4, + "maxOutputTokens": MAX_OUTPUT_TOKENS, + }, + "safetySettings": [ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_NONE", + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_NONE", + }, + ], + } + + stream_endpoint = ( + f"https://generativelanguage.googleapis.com/v1beta/models/" + f"{self._model}:streamGenerateContent" + ) + + logger.info(f"Starting chat stream for {repo_full_name}") + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + stream_endpoint, + params={"key": self._api_key}, + json=payload, + ) as response: + logger.info(f"Gemini stream response status: {response.status_code}") + if response.status_code >= 400: + error_text = await response.aread() + logger.error(f"Gemini stream error: {error_text}") + yield self._format_protocol_error( + f"Gemini API error: {response.status_code}" + ) + return + + decoder = json.JSONDecoder() + buffer = "" + has_yielded = False + try: + async for chunk in response.aiter_text(): + logger.info(f"Received chunk length: {len(chunk)}") + buffer += chunk + while True: + # Skip whitespace + buffer = buffer.lstrip() + if not buffer: + break + + # Skip array start/end/separators + if buffer[0] in ["[", "]", ","]: + buffer = buffer[1:] + continue + + try: + obj, idx = decoder.raw_decode(buffer) + # logger.info(f"Parsed object keys: {obj.keys()}") + + # Process obj + candidates = obj.get("candidates", []) + if candidates: + parts = ( + candidates[0] + .get("content", {}) + .get("parts", []) + ) + if parts: + text_chunk = parts[0].get("text", "") + if text_chunk: + yield self._format_protocol_text(text_chunk) + has_yielded = True + else: + # Check for promptFeedback or just metadata + if "promptFeedback" in obj: + block_reason = obj["promptFeedback"].get( + "blockReason" + ) + if block_reason: + msg = ( + f"\n[Response blocked: {block_reason}]" + ) + logger.warning( + f"Response blocked: {block_reason}" + ) + yield self._format_protocol_text(msg) + has_yielded = True + elif "usageMetadata" in obj: + logger.info("Received usage metadata") + else: + logger.warning( + f"Unexpected Gemini response chunk: {obj}" + ) + + buffer = buffer[idx:] + except json.JSONDecodeError: + # Incomplete JSON, wait for more data + break + except Exception as e: + logger.error(f"Error during stream iteration: {e}") + yield self._format_protocol_error(f"Stream error: {str(e)}") + return + + if not has_yielded: + logger.warning("Stream finished without yielding any content") + yield self._format_protocol_text("I couldn't generate a response.") + + def _format_protocol_error(self, error: str) -> str: + """Format error for Vercel AI data stream v1.""" + return f"0:{json.dumps('Error: ' + error)}\n" + + def _format_protocol_text(self, text: str) -> str: + """Format text chunk for Vercel AI data stream v1.""" + return f"0:{json.dumps(text)}\n" + def _find_commits_for_window( self, repo_full_name: str, window: list[str] ) -> list[RepoCommitChunk]: - # This is a simplified version. In reality, we might need to traverse the graph or rely on authored_at. - # For now, let's fetch all chunks for the repo and filter in python as in gemini.py - # Optimization: In a real app, we'd query by range if we had order, or just fetch all (expensive). + # This is a simplified version. In reality, we might need to traverse the + # graph or rely on authored_at. + # For now, let's fetch all chunks for the repo and filter in python as in + # gemini.py + # Optimization: In a real app, we'd query by range if we had order, or + # just fetch all (expensive). # Let's try to fetch by repo_full_name and filter. all_chunks = ( @@ -138,6 +297,18 @@ async def _call_gemini(self, prompt: str) -> str: "temperature": 0.4, "maxOutputTokens": MAX_OUTPUT_TOKENS, }, + "safetySettings": [ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_NONE", + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_NONE", + }, + ], } try: diff --git a/commitly-backend/app/services/roadmap_repository.py b/commitly-backend/app/services/roadmap_repository.py index 6b4eace..72e1fbe 100644 --- a/commitly-backend/app/services/roadmap_repository.py +++ b/commitly-backend/app/services/roadmap_repository.py @@ -11,6 +11,7 @@ from app.models.roadmap import ( GeneratedRoadmap, + GuideChatSession, RoadmapRepoSummary, RoadmapResponse, TimelineStage, @@ -374,6 +375,21 @@ def operation() -> None: self._run_with_schema_guard(operation) + def clear_chat_history(self, user_id: str, full_name: str) -> None: + """Delete all chat sessions for a user and repository.""" + + def operation() -> None: + try: + self._session.query(GuideChatSession).filter_by( + user_id=user_id, repo_full_name=full_name + ).delete() + self._session.commit() + except SQLAlchemyError: + self._session.rollback() + raise + + self._run_with_schema_guard(operation) + def list(self, user_id: str | None) -> list[RoadmapResponse]: if not user_id: return [] diff --git a/commitly-backend/app/services/roadmap_service.py b/commitly-backend/app/services/roadmap_service.py index 2e35b11..b4eedfc 100644 --- a/commitly-backend/app/services/roadmap_service.py +++ b/commitly-backend/app/services/roadmap_service.py @@ -532,6 +532,7 @@ def desync(): states = self._pin_store.list_states(user_id) had_state = any(state.repo_full_name == full_name for state in states) self._pin_store.unpin(user_id, full_name) + self._pin_store.clear_chat_history(user_id, full_name) if had_state: self._result_store.decrement_sync_count(full_name) diff --git a/commitly-frontend/app/api/chat/history/route.ts b/commitly-frontend/app/api/chat/history/route.ts new file mode 100644 index 0000000..e82384f --- /dev/null +++ b/commitly-frontend/app/api/chat/history/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "edge"; + +const backendBase = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"; + +async function forward(req: NextRequest) { + const url = new URL(req.url); + const search = url.search ? url.search : ""; + const target = `${backendBase}/api/v1/roadmap/chat/history${search}`; + + const method = req.method; + const headers: Record = { + "Content-Type": "application/json", + }; + const auth = req.headers.get("authorization"); + if (auth) headers.Authorization = auth; + + const init: RequestInit = { + method, + headers, + cache: "no-store", + }; + + if (method !== "GET") { + const body = await req.text(); + init.body = body; + } + + const res = await fetch(target, init); + if (!res.ok) { + const text = await res.text(); + return NextResponse.json({ error: text || res.statusText }, { status: res.status }); + } + + if (method === "GET") { + const data = await res.json(); + return NextResponse.json(data, { status: 200 }); + } + + return NextResponse.json({ ok: true }, { status: 200 }); +} + +export async function GET(req: NextRequest) { + return forward(req); +} + +export async function POST(req: NextRequest) { + return forward(req); +} diff --git a/commitly-frontend/app/api/chat/route.ts b/commitly-frontend/app/api/chat/route.ts new file mode 100644 index 0000000..4e956d6 --- /dev/null +++ b/commitly-frontend/app/api/chat/route.ts @@ -0,0 +1,58 @@ +import { type NextRequest, NextResponse } from "next/server"; + +export const runtime = "edge"; + +export async function POST(req: NextRequest) { + console.log("Chat API route hit"); + try { + const body = await req.json(); + const { messages, ...rest } = body; + + // Get the backend URL from environment + const apiBaseUrl = + process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"; + const backendUrl = `${apiBaseUrl}/api/v1/roadmap/chat`; + + // Forward the request to the backend + const response = await fetch(backendUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + // Forward the Authorization header if present + Authorization: req.headers.get("Authorization") || "", + }, + body: JSON.stringify({ + messages, + ...rest, + }), + cache: "no-store", + // @ts-expect-error - duplex is needed for some node environments but might not be strictly needed for edge, adding for safety if runtime changes + duplex: "half", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Backend chat error:", response.status, errorText); + return NextResponse.json( + { error: `Backend error: ${response.statusText}` }, + { status: response.status } + ); + } + + // Stream the response back + return new Response(response.body, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Vercel-AI-Data-Stream": "v1", + }, + }); + } catch (error) { + console.error("Chat route error:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 } + ); + } +} diff --git a/commitly-frontend/app/layout.tsx b/commitly-frontend/app/layout.tsx index 2bbc7c9..62d8374 100644 --- a/commitly-frontend/app/layout.tsx +++ b/commitly-frontend/app/layout.tsx @@ -8,7 +8,10 @@ import type { Metadata, Viewport } from "next"; import { Inter, JetBrains_Mono } from "next/font/google"; import { type ReactNode, Suspense } from "react"; import HomeBackground from "@/components/layout/home-background"; +import { ResizableLayout } from "@/components/layout/resizable-layout"; +import RightSidebar from "@/components/layout/right-sidebar"; import SidebarWrapper from "@/components/layout/sidebar/sidebar-wrapper"; +import { LayoutProvider } from "@/components/providers/layout-provider"; import { RoadmapCatalogProvider } from "@/components/providers/roadmap-catalog-provider"; const inter = Inter({ @@ -65,15 +68,17 @@ export default function RootLayout({ children }: { children: ReactNode }) { className={`${inter.variable} ${jetBrainsMono.variable} bg-background text-foreground`} > -
+ - - - -
- {children} -
-
+
+ + + +
+ {children} +
+
+
diff --git a/commitly-frontend/app/page.tsx b/commitly-frontend/app/page.tsx index 275842c..8f069e3 100644 --- a/commitly-frontend/app/page.tsx +++ b/commitly-frontend/app/page.tsx @@ -89,15 +89,11 @@ export default function Home() { const canonicalUrl = `https://github.com/${identity.fullName}`; markPending(identity); - const params = new URLSearchParams({ - repoUrl: canonicalUrl, - fullName: identity.fullName, - intent: "generate", - }); - - router.push(`/repo/${identity.slug}/timeline?${params.toString()}`); - setRepoLink(""); - setIsSubmitting(false); + const params = new URLSearchParams(); + if (identity.fullName) params.set("fullName", identity.fullName); + if (canonicalUrl) params.set("repoUrl", canonicalUrl); + params.set("intent", "generate"); + router.push(`/repo/${identity.slug}?view=timeline&${params.toString()}`); }; const handleConnectGithub = async () => { diff --git a/commitly-frontend/app/repo/[repoId]/guide/page.tsx b/commitly-frontend/app/repo/[repoId]/guide/page.tsx index 6879f3e..b139889 100644 --- a/commitly-frontend/app/repo/[repoId]/guide/page.tsx +++ b/commitly-frontend/app/repo/[repoId]/guide/page.tsx @@ -2,7 +2,9 @@ import { useAuth } from "@clerk/nextjs"; import { - ChevronDown, + Check, + ChevronLeft, + ChevronRight, Copy, Edit2, SendHorizontal, @@ -14,6 +16,7 @@ import { useParams, useSearchParams } from "next/navigation"; import { type ChangeEvent, type FormEvent, + useCallback, useEffect, useMemo, useRef, @@ -25,20 +28,23 @@ import TabSwitch from "@/components/navigation/tab-switch"; import { useRoadmapCatalog } from "@/components/providers/roadmap-catalog-provider"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; import { Textarea } from "@/components/ui/textarea"; -import { repoService } from "@/lib/services/repos"; +import { useChatTree } from "@/lib/hooks/useChatTree"; +import { cn } from "@/lib/utils"; +import { + Panel, + PanelGroup, + PanelResizeHandle, +} from "react-resizable-panels"; +import { useLayout } from "@/components/providers/layout-provider"; export default function RepoGuidePage() { const params = useParams(); const repoId = params.repoId as string; const searchParams = useSearchParams(); const { isSignedIn, getToken } = useAuth(); - const { getBySlug } = useRoadmapCatalog(); + const { getBySlug, yourRepos } = useRoadmapCatalog(); + const { setLeftSidebarCollapsed } = useLayout(); const cachedRecord = getBySlug(repoId); @@ -61,32 +67,22 @@ export default function RepoGuidePage() { return null; }, [cachedRecord]); - const [message, setMessage] = useState(""); - const [chatHistory, setChatHistory] = useState< - Array<{ id: string; role: "user" | "guide"; message: string }> - >([]); - const [isLoading, setIsLoading] = useState(false); - const bottomRef = useRef(null); - const textareaRef = useRef(null); - - // Initialize chat history from static data if available and empty - useEffect(() => { - if ( - activeData?.guideThread && - chatHistory.length === 0 && - activeData.guideThread.length > 0 - ) { - setChatHistory( - activeData.guideThread.map((item) => ({ - ...item, - role: item.role as "user" | "guide", - })) - ); - } - }, [activeData, chatHistory.length]); + const isSynced = useMemo(() => { + if (!activeData) return false; + return yourRepos.some((r) => r.repo_full_name === activeData.name); + }, [activeData, yourRepos]); const stageId = searchParams?.get("stage"); + const authHeaders = useCallback(async () => { + const token = await getToken(); + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return headers; + }, [getToken]); + const stageContext = useMemo(() => { if (!(activeData && stageId)) { return null; @@ -94,263 +90,140 @@ export default function RepoGuidePage() { return activeData.timeline.find((stage) => stage.id === stageId) ?? null; }, [activeData, stageId]); + const { + messages, + treeState, + sendMessage, + editMessage, + navigateBranch, + isLoading, + input, + setInput, + setFeedback, + } = useChatTree({ + api: "/api/chat", + historyApi: "/api/chat/history", + repo_full_name: activeData?.name ?? repoId, + stage_id: stageId ?? null, + authHeaders, + persistEnabled: isSynced, + }); + + const bottomRef = useRef(null); + const textareaRef = useRef(null); + const [editingMessageId, setEditingMessageId] = useState(null); + const [editContent, setEditContent] = useState(""); + const [copiedId, setCopiedId] = useState(null); + + useEffect(() => { + if (stageId) { + setLeftSidebarCollapsed(true); + } else { + setLeftSidebarCollapsed(false); + } + }, [stageId, setLeftSidebarCollapsed]); + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, []); + }, [messages]); + + const getRequestOptions = async () => { + const token = await getToken(); + return { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: { + repo_full_name: `${activeData?.identity.owner}/${activeData?.identity.repoName}`, + stage_id: stageId ?? undefined, + }, + }; + }; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - if (!(isSignedIn && message.trim()) || isLoading || !activeData) { + console.log("[GuidePage] submit", { + isSignedIn, + inputLength: input.trim().length, + isLoading, + hasActiveData: !!activeData, + }); + if (!(isSignedIn && input.trim()) || isLoading || !activeData) { return; } - const userMsg = message.trim(); - setMessage(""); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } - const newHistory = [ - ...chatHistory, - { id: Date.now().toString(), role: "user" as const, message: userMsg }, - ]; - setChatHistory(newHistory); - setIsLoading(true); - - try { - const token = await getToken(); - const response = await repoService.chat( - activeData.identity.owner, - activeData.identity.repoName, - userMsg, - stageId ?? undefined, - token ?? undefined - ); - - if (response.ok && response.data) { - setChatHistory((prev) => [ - ...prev, - { - id: (Date.now() + 1).toString(), - role: "guide", - message: response.data!.response, - }, - ]); - } else { - // Fallback error message - setChatHistory((prev) => [ - ...prev, - { - id: (Date.now() + 1).toString(), - role: "guide", - message: "Sorry, I encountered an error. Please try again.", - }, - ]); - } - } catch (error) { - console.error("Chat error:", error); - setChatHistory((prev) => [ - ...prev, - { - id: (Date.now() + 1).toString(), - role: "guide", - message: "Sorry, I encountered an error. Please try again.", - }, - ]); - } finally { - setIsLoading(false); - } + const options = await getRequestOptions(); + await sendMessage(input, options); }; const handleInputChange = (event: ChangeEvent) => { - setMessage(event.target.value); + setInput(event.target.value); if (textareaRef.current) { textareaRef.current.style.height = "auto"; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; } }; + const handleEditStart = (messageId: string, currentContent: string) => { + setEditingMessageId(messageId); + setEditContent(currentContent); + }; + + const handleEditSubmit = async (messageId: string) => { + if (!editContent.trim()) return; + setEditingMessageId(null); + const options = await getRequestOptions(); + await editMessage(messageId, editContent, options); + }; + + const handleCopy = (content: string, id: string) => { + navigator.clipboard.writeText(content); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + }; + if (!activeData) { - return null; + return ( +
+
+

Loading guide...

+
+ ); } - return ( -
-
-
-

Guide

-

{activeData.name}

-
- -
+ const hasStage = Boolean(stageContext); -
- {stageContext && ( -
-
-
- Timeline context -
-

{stageContext.title}

-

- {stageContext.summary} -

-
-
- + const renderChatInterface = () => ( +
+
+ {messages.length === 0 ? ( +
+
+

+ No guide activity yet. Ask for a walkthrough to start the conversation. +

+
+ ) : ( + (messages as any[]).map((messageItem) => { + const node = treeState.messages[messageItem.id]; + let siblings: string[] = []; + if (node?.parentId) { + const parent = treeState.messages[node.parentId]; + if (parent) siblings = parent.childrenIds; + } else if (node) { + siblings = treeState.rootIds || []; + } - {/* Goals Section */} - {stageContext.goals && stageContext.goals.length > 0 && ( -
-
-

- Goals -

-
-
-
    - {stageContext.goals.map((goal: string, idx: number) => ( -
  • - - {goal} -
  • - ))} -
-
- )} - - {/* Tasks Section */} -
-
-

- {isSignedIn ? "Tasks" : "Tasks · Sign in to start"} -

-
-
-
- {stageContext.tasks.map((task, idx) => ( -
-

- {task.title} -

-
-

- {task.description} -

-
- {task.file_path && ( -
- - File: - - {task.file_path} -
- )} - {task.code_snippet && ( -
-
- $ {task.code_snippet} -
-
- )} -
- ))} -
-
+ const siblingCount = siblings.length; + const currentSiblingIndex = siblings.indexOf(messageItem.id); - {/* Code Examples Section */} - {stageContext.code_examples && - stageContext.code_examples.length > 0 && ( -
-
-

- Code Examples -

-
-
-
- {stageContext.code_examples.map((example: any, idx: number) => ( - -
- -
-
- - {example.file} - - - {example.language} - -
-

- {example.description} -

-
- -
- -
-

- {example.description} -

-
-                                {example.snippet}
-                              
-
-
-
-
- ))} -
-
- )} - - {/* Resources Section */} - {stageContext.resources.length > 0 && ( -
-
- {stageContext.resources.map((resource: { label: string; href: string }) => ( - - {resource.label} - - - ))} -
-
- )} -
- )} -
- {chatHistory.length === 0 ? ( -
- No guide activity yet. Ask for a walkthrough to start the - conversation. -
- ) : ( - chatHistory.map((messageItem) => ( -
- {messageItem.role === "guide" ? ( -
+ return ( +
+ {messageItem.role !== "user" ? ( +
- {messageItem.message} + {messageItem.content}
) : ( -
-

- {messageItem.message} -

-
- - -
+
+ {editingMessageId === messageItem.id ? ( +
+