From f4fc5852416a85e8430005e093092afd6a8288fa Mon Sep 17 00:00:00 2001 From: alim Date: Mon, 24 Nov 2025 14:58:50 +0800 Subject: [PATCH 1/8] feat: implement chat tree functionality and integrate AI SDK - Added a new hook `useChatTree` to manage chat messages in a tree structure. - Integrated the `@ai-sdk/react` package for AI interactions. - Updated the timeline component to remove unused imports and optimize rendering. - Refactored the `TimelineNodeCard` component for better readability. - Added new dependencies: `uuid` for unique ID generation and `ai` for AI functionalities. - Updated package.json and package-lock.json to reflect new dependencies and versions. --- TEACHING_ROADMAP_IMPLEMENTATION.md | 22 - UI_UX_TWEAKS.md | 38 -- commitly-backend/app/api/roadmap.py | 34 +- commitly-backend/app/models/roadmap.py | 3 +- commitly-backend/app/services/ai/chat.py | 139 +++++- .../app/repo/[repoId]/guide/page.tsx | 432 ++++++++++-------- .../app/repo/[repoId]/timeline/page.tsx | 53 ++- commitly-frontend/lib/hooks/useChatTree.ts | 202 ++++++++ commitly-frontend/lib/services/repos.ts | 5 +- commitly-frontend/package-lock.json | 198 ++++++-- commitly-frontend/package.json | 6 +- 11 files changed, 797 insertions(+), 335 deletions(-) delete mode 100644 TEACHING_ROADMAP_IMPLEMENTATION.md delete mode 100644 UI_UX_TWEAKS.md create mode 100644 commitly-frontend/lib/hooks/useChatTree.ts 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/app/api/roadmap.py b/commitly-backend/app/api/roadmap.py index 89abcba..b4e4c2f 100644 --- a/commitly-backend/app/api/roadmap.py +++ b/commitly-backend/app/api/roadmap.py @@ -13,7 +13,6 @@ from app.models.roadmap import ( CatalogPage, ChatRequest, - ChatResponse, RatingRequest, RatingResponse, RoadmapRequest, @@ -251,12 +250,12 @@ 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: +) -> StreamingResponse: """ Chat with the AI guide about the repository or a specific stage. """ @@ -266,10 +265,27 @@ async def chat_with_guide( 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 ChatResponse(response=response) + return StreamingResponse( + chat_service.chat_stream( + repo_full_name=payload.repo_full_name, + messages=messages, + stage_id=payload.stage_id, + ), + media_type="text/event-stream", + headers={ + "x-vercel-ai-ui-message-stream": "v1", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) diff --git a/commitly-backend/app/models/roadmap.py b/commitly-backend/app/models/roadmap.py index d6197b0..a20f19b 100644 --- a/commitly-backend/app/models/roadmap.py +++ b/commitly-backend/app/models/roadmap.py @@ -304,9 +304,10 @@ 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): diff --git a/commitly-backend/app/services/ai/chat.py b/commitly-backend/app/services/ai/chat.py index 7dba79c..3502859 100644 --- a/commitly-backend/app/services/ai/chat.py +++ b/commitly-backend/app/services/ai/chat.py @@ -1,7 +1,9 @@ from __future__ import annotations +import json import logging from typing import Optional +import uuid import httpx from sqlalchemy.orm import Session @@ -14,7 +16,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 +28,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 +37,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 +53,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 +90,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 +112,110 @@ 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_sse_error( + "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, + }, + } + + stream_endpoint = ( + f"https://generativelanguage.googleapis.com/v1beta/models/" + f"{self._model}:streamGenerateContent" + ) + + 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: + if response.status_code >= 400: + yield self._format_sse_error( + f"Gemini API error: {response.status_code}" + ) + return + + # Vercel AI SDK Data Stream Protocol + message_id = f"msg-{uuid.uuid4().hex}" + yield self._format_sse({"type": "start", "messageId": message_id}) + + text_stream_id = "text-1" + yield self._format_sse({"type": "text-start", "id": text_stream_id}) + + async for line in response.aiter_lines(): + line = line.strip() + if not line: + continue + if line == "[" or line == "]": + continue + if line.startswith(","): + line = line[1:].strip() + + try: + chunk_data = json.loads(line) + candidates = chunk_data.get("candidates", []) + if candidates: + text_chunk = ( + candidates[0] + .get("content", {}) + .get("parts", [])[0] + .get("text", "") + ) + if text_chunk: + yield self._format_sse( + { + "type": "text-delta", + "id": text_stream_id, + "delta": text_chunk, + } + ) + except json.JSONDecodeError: + continue + + yield self._format_sse({"type": "text-end", "id": text_stream_id}) + yield self._format_sse({"type": "finish"}) + yield "data: [DONE]\n\n" + + def _format_sse(self, payload: dict) -> str: + return f"data: {json.dumps(payload, separators=(',', ':'))}\n\n" + + def _format_sse_error(self, error: str) -> str: + return self._format_sse( + {"type": "text-delta", "id": "error", "delta": f"Error: {error}"} + ) + 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 = ( diff --git a/commitly-frontend/app/repo/[repoId]/guide/page.tsx b/commitly-frontend/app/repo/[repoId]/guide/page.tsx index 6879f3e..c9ff42c 100644 --- a/commitly-frontend/app/repo/[repoId]/guide/page.tsx +++ b/commitly-frontend/app/repo/[repoId]/guide/page.tsx @@ -3,6 +3,8 @@ import { useAuth } from "@clerk/nextjs"; import { ChevronDown, + ChevronLeft, + ChevronRight, Copy, Edit2, SendHorizontal, @@ -31,7 +33,8 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Textarea } from "@/components/ui/textarea"; -import { repoService } from "@/lib/services/repos"; +import { env } from "@/lib/config/env"; +import { useChatTree } from "@/lib/hooks/useChatTree"; export default function RepoGuidePage() { const params = useParams(); @@ -61,30 +64,6 @@ 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 stageId = searchParams?.get("stage"); const stageContext = useMemo(() => { @@ -94,82 +73,73 @@ export default function RepoGuidePage() { return activeData.timeline.find((stage) => stage.id === stageId) ?? null; }, [activeData, stageId]); + const { + messages, + treeState, + sendMessage, + editMessage, + navigateBranch, + isLoading, + input, + setInput, + } = useChatTree({ + api: `${env.apiBaseUrl}/api/v1/roadmap/chat`, + }); + + const bottomRef = useRef(null); + const textareaRef = useRef(null); + const [editingMessageId, setEditingMessageId] = useState(null); + const [editContent, setEditContent] = useState(""); + 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) { + 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); + }; + if (!activeData) { return null; } @@ -280,41 +250,43 @@ export default function RepoGuidePage() {
- {stageContext.code_examples.map((example: any, idx: number) => ( - -
- -
-
- - {example.file} - - - {example.language} - + {stageContext.code_examples.map( + (example: any, idx: number) => ( + +
+ +
+
+ + {example.file} + + + {example.language} + +
+

+ {example.description} +

-

- {example.description} -

-
- - - -
-

- {example.description} -

-
-                                {example.snippet}
-                              
-
-
-
- - ))} + + + +
+

+ {example.description} +

+
+                                  {example.snippet}
+                                
+
+
+
+ + ) + )}
)} @@ -323,95 +295,169 @@ export default function RepoGuidePage() { {stageContext.resources.length > 0 && (
- {stageContext.resources.map((resource: { label: string; href: string }) => ( - - {resource.label} - - - ))} + {stageContext.resources.map( + (resource: { label: string; href: string }) => ( + + {resource.label} + + + ) + )}
)} )}
- {chatHistory.length === 0 ? ( + {messages.length === 0 ? (
No guide activity yet. Ask for a walkthrough to start the conversation.
) : ( - chatHistory.map((messageItem) => ( -
- {messageItem.role === "guide" ? ( - - ) : ( -
-

- {messageItem.message} -

-
- - + (messages as any[]).map((messageItem) => { + const node = treeState.messages[messageItem.id]; + const parentNode = node?.parentId + ? treeState.messages[node.parentId] + : null; + const siblingCount = parentNode?.childrenIds.length ?? 0; + const currentSiblingIndex = + parentNode?.childrenIds.indexOf(messageItem.id) ?? 0; + + return ( +
+ {messageItem.role !== "user" ? ( + + ) : ( +
+ {editingMessageId === messageItem.id ? ( +
+