From 784254ab747c205e01796475d78203cc888491af Mon Sep 17 00:00:00 2001 From: Seng Rith <50646727+afterrburn@users.noreply.github.com> Date: Wed, 17 Sep 2025 07:05:34 -0600 Subject: [PATCH 1/8] Seng/chat prototype (#279) * add totalChunks to metadata for tracing * improve RAG retrieval process * POC UI for chat based documentation * update Start / Continue course * expand text * fix scrollbar problem and chat input resizing * adding progress tracker * center the progress bar * testing out new terminal component and websocket servert * fix terminal issue not staying on * fix weird terminal display * fix self is not defined error * remove unnecessary terminal message * typo * fix weird flow * remove duplicated butotn * playing with coding web server * remove websocket server * creating api for tutorials * fix interface * modify tutorials workflow -- vibecoded * dummy demo code execution api next.js * New pulse agent using response api tools calling * re-build the entire Pulse agent with new design * adding tutorial step workflow * simplify tutorial reader to have consistent api * cleaning up some more steps * breaking frontend to smaller components; * link doc-qa to pulse agent * removing unused import * fix chat input box and have split pane for code editor * enhancing file display * simplify chat interface -- removing unnecessary code block displays * add editor close button * make side bar icons smaller * implement chunk streaming structure * clean up some items * Revert "Implement Copy Page Dropdown Functionality (#239)" This reverts commit 5eb9f16a53d8ed4d11553bec9f3c7426675b473e. * fix tutorial step data handling issue * add kv store api service * remove unused interfaces * remove unneeded conversation type * reformat chat history * add kv store api * Simplify and refactor chat to connect with kv store * add uuid package * update example env * share session context * removing debug * Adding session cache with SWR * add .env to gitignore * sync with main * adjust chat message area width and dynamic spacing with sessionsbar * add code editor content * remove redundant comments * display tutorial instruction content * add user based session management * enable split pane resize * adding sessions cursor * sessions paginated loading * clean up env variables * enabling direct llm access flag * add title generation * remove session update redundancy * render session messages directly * fix streaming bug on UI * merge conflict resolution * remove tutorial agent set up that is not currently needed * remove package json * rebuilt package json and remove /api/chat and /api/terminal that were mock/test * delete dummy terminal websocket server * Add tutorial structure rules and enhance tutorial API responses - Introduced a new markdown file defining the structure and authoring guidelines for tutorials. - Updated the tutorial API to return detailed step data, including snippets and metadata. - Refactored tutorial step fetching logic to improve error handling and data retrieval. - Implemented a new `` component for rendering code snippets from files. - Enhanced chat message rendering to support tutorial content and snippets. * chore(lockfile): sync package-lock with package.json to fix npm ci (add data-uri-to-buffer@2.0.2) * sync package * fix build error * synchronize name of totalSteps * fix linter failure * cleaning up debug log and unused modules * remove debug log from ChatMessage * remove dummy tutorial content * simplify code pieces * add total steps * remove unused components * removing unused module * Remove integration md * replace div with interactable button * remove unused import * toIsoString formatting * gracefully handle setKVValue error * improve tool param wording * remove unused websocket server * add user tutorial status * add tutorial state management * refactor tutorial state route handlers to improve JSON body parsing and error handling * update ChatMessage component to format code snippets with labels above code fences for improved readability * remove python tutorial mdx * Fix CodeRabbit issues: implement validation middleware and improve error handling (#283) * Fix CodeRabbit issues: implement validation middleware, fix config imports, handle KV errors - Add comprehensive body validation middleware for /sessions, /tutorials, /users endpoints - Fix config import issues by moving to static imports at top of files - Add proper KV persistence error handling with success checks - Validate tutorialId as string and prevent path traversal attacks - Fix implicit any types on request body parameters - Replace parseInt with Number.parseInt for consistency - Add proper 400 error responses with detailed validation messages - Use existing types from app/chat/types.ts for validation - Prevent TypeError when no progress exists by handling 404 responses gracefully Co-Authored-By: srith@agentuity.com * Fix TypeScript compilation errors in validation middleware - Add SessionMessageValidationResult and SessionMessageOnlyValidationResult types - Fix validation function return type mismatches in session routes - Add proper bounds checking for stepIndex in tutorial route - Ensure all validation errors use consistent error structure - Generate missing docs.json file to resolve import errors All TypeScript compilation errors resolved, ready for CI Co-Authored-By: srith@agentuity.com * Refactor validation middleware to be generic and scalable - Add FieldSchema and ValidationSchema interfaces for declarative validation - Implement validateField and validateObject for schema-based validation - Add overloaded parseAndValidateJSON to accept both validators and schemas - Maintain backward compatibility with existing validation functions - Fix TypeScript compilation errors with explicit Message type annotations - Enable reusable validation for current and future types Co-Authored-By: srith@agentuity.com * Refactor validation to use Zod schemas and eliminate duplicate source of truth - Replace TypeScript interfaces with Zod schemas in app/chat/types.ts - Derive types using z.infer instead of separate interfaces - Update validation middleware to use Zod's safeParse and error handling - Maintain all existing validation behavior while using industry-standard Zod - Fix TypeScript compilation errors and import issues - All API endpoints now use consistent Zod-based validation This eliminates the duplicate source of truth between validation schemas and TypeScript interfaces, making the codebase more maintainable and following modern best practices. Co-Authored-By: srith@agentuity.com * Complete Zod migration for messages API endpoint - Replace custom validation logic with SessionMessageRequestSchema - Simplify validation code by using Zod's built-in validation - Maintain all existing functionality while using industry-standard validation Co-Authored-By: srith@agentuity.com * Complete Zod migration: remove redundant interfaces and convert utility functions - Remove unused SessionMessageValidationResult and SessionMessageOnlyValidationResult interfaces - Convert validateStepNumber and validateTutorialId to use Zod schemas internally - Add StepNumberSchema and TutorialIdSchema for consistent validation - Maintain backward compatibility with existing function signatures - Complete elimination of duplicate source of truth between validation and types - All validation now uses Zod schemas as single source of truth Co-Authored-By: srith@agentuity.com * delete lib/validation/types.ts unused module * defensively check tutorials state * update tools description and enhance the path checking --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: srith@agentuity.com Co-authored-by: afterrburn * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Seng Rith <50646727+afterrburn@users.noreply.github.com> * fix typo * clean up * small fixes * revert css * remove tutorial * remove Tutorial page * remove outdated readme * remove unnecessary dependencies * remove debug logging * example of how tutorial is structured * Revert "example of how tutorial is structured" This reverts commit 6d70c4e69081051a5eb4473a1045c8a3d262565e. * move helper out of the POST body --------- Signed-off-by: Seng Rith <50646727+afterrburn@users.noreply.github.com> Co-authored-by: afterrburn Co-authored-by: Seng Rith <50646727+senghorn@users.noreply.github.com> Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .cursor/rules/overview.mdc | 6 +- .cursor/rules/tutorials-structure.mdc | 69 + .env.example | 7 +- .gitignore | 1 + agent-docs/agentuity.yaml | 3 + agent-docs/package.json | 2 +- agent-docs/src/agents/agent-pulse/README.md | 102 + .../src/agents/agent-pulse/context/builder.ts | 54 + agent-docs/src/agents/agent-pulse/index.ts | 143 + .../src/agents/agent-pulse/request/parser.ts | 49 + .../src/agents/agent-pulse/request/types.ts | 16 + agent-docs/src/agents/agent-pulse/state.ts | 46 + .../src/agents/agent-pulse/state/manager.ts | 54 + .../agents/agent-pulse/streaming/processor.ts | 115 + .../src/agents/agent-pulse/streaming/types.ts | 48 + agent-docs/src/agents/agent-pulse/tools.ts | 103 + agent-docs/src/agents/agent-pulse/tutorial.ts | 98 + agent-docs/src/agents/doc-qa/prompt.ts | 1 - agent-docs/src/agents/doc-qa/rag.ts | 7 +- app/(docs)/[[...slug]]/page.tsx | 2 + .../sessions/[sessionId]/messages/route.ts | 374 + app/api/sessions/[sessionId]/route.ts | 266 + app/api/sessions/route.ts | 149 + app/api/tutorials/[id]/route.ts | 43 + .../[id]/steps/[stepNumber]/route.ts | 178 + app/api/tutorials/route.ts | 77 + app/api/users/tutorial-state/route.ts | 119 + app/chat/SessionContext.tsx | 20 + app/chat/[sessionId]/page.tsx | 233 + app/chat/components/ChatInput.tsx | 74 + app/chat/components/ChatMessage.tsx | 155 + app/chat/components/ChatMessagesArea.tsx | 70 + app/chat/components/CodeBlock.tsx | 62 + app/chat/components/CodeEditor.tsx | 135 + app/chat/components/MarkdownRenderer.tsx | 85 + app/chat/components/SessionSidebar.tsx | 205 + .../components/SessionSidebarSkeleton.tsx | 100 + app/chat/components/TutorialFileChip.tsx | 33 + app/chat/layout.tsx | 114 + app/chat/page.tsx | 57 + app/chat/services/sessionService.ts | 296 + app/chat/types.ts | 101 + app/chat/utils/dateUtils.ts | 37 + app/chat/utils/useAutoResize.ts | 45 + app/chat/utils/useStreaming.ts | 209 + app/global.css | 61 + components/CodeFromFiles.tsx | 92 + components/DynamicIsland/CompactView.tsx | 62 + components/DynamicIsland/DynamicIsland.tsx | 111 + components/DynamicIsland/ExpandedView.tsx | 116 + components/DynamicIsland/IslandWrapper.tsx | 47 + components/DynamicIsland/README.md | 133 + components/DynamicIsland/index.ts | 23 + components/DynamicIsland/types.ts | 47 + components/DynamicIsland/useTutorial.ts | 134 + components/DynamicIsland/utils.ts | 114 + components/ui/skeleton.tsx | 16 + lib/config.ts | 18 + lib/env.ts | 80 +- lib/kv-store.ts | 289 + lib/tutorial/all-tutorials-reader.ts | 77 + lib/tutorial/index.ts | 3 + lib/tutorial/state-manager.ts | 121 + lib/tutorial/tutorial-reader.ts | 144 + lib/tutorial/types.ts | 23 + lib/validation/middleware.ts | 141 + middleware.ts | 22 +- package-lock.json | 7211 +++++++---------- package.json | 21 +- 69 files changed, 8979 insertions(+), 4290 deletions(-) create mode 100644 .cursor/rules/tutorials-structure.mdc create mode 100644 agent-docs/src/agents/agent-pulse/README.md create mode 100644 agent-docs/src/agents/agent-pulse/context/builder.ts create mode 100644 agent-docs/src/agents/agent-pulse/index.ts create mode 100644 agent-docs/src/agents/agent-pulse/request/parser.ts create mode 100644 agent-docs/src/agents/agent-pulse/request/types.ts create mode 100644 agent-docs/src/agents/agent-pulse/state.ts create mode 100644 agent-docs/src/agents/agent-pulse/state/manager.ts create mode 100644 agent-docs/src/agents/agent-pulse/streaming/processor.ts create mode 100644 agent-docs/src/agents/agent-pulse/streaming/types.ts create mode 100644 agent-docs/src/agents/agent-pulse/tools.ts create mode 100644 agent-docs/src/agents/agent-pulse/tutorial.ts create mode 100644 app/api/sessions/[sessionId]/messages/route.ts create mode 100644 app/api/sessions/[sessionId]/route.ts create mode 100644 app/api/sessions/route.ts create mode 100644 app/api/tutorials/[id]/route.ts create mode 100644 app/api/tutorials/[id]/steps/[stepNumber]/route.ts create mode 100644 app/api/tutorials/route.ts create mode 100644 app/api/users/tutorial-state/route.ts create mode 100644 app/chat/SessionContext.tsx create mode 100644 app/chat/[sessionId]/page.tsx create mode 100644 app/chat/components/ChatInput.tsx create mode 100644 app/chat/components/ChatMessage.tsx create mode 100644 app/chat/components/ChatMessagesArea.tsx create mode 100644 app/chat/components/CodeBlock.tsx create mode 100644 app/chat/components/CodeEditor.tsx create mode 100644 app/chat/components/MarkdownRenderer.tsx create mode 100644 app/chat/components/SessionSidebar.tsx create mode 100644 app/chat/components/SessionSidebarSkeleton.tsx create mode 100644 app/chat/components/TutorialFileChip.tsx create mode 100644 app/chat/layout.tsx create mode 100644 app/chat/page.tsx create mode 100644 app/chat/services/sessionService.ts create mode 100644 app/chat/types.ts create mode 100644 app/chat/utils/dateUtils.ts create mode 100644 app/chat/utils/useAutoResize.ts create mode 100644 app/chat/utils/useStreaming.ts create mode 100644 components/CodeFromFiles.tsx create mode 100644 components/DynamicIsland/CompactView.tsx create mode 100644 components/DynamicIsland/DynamicIsland.tsx create mode 100644 components/DynamicIsland/ExpandedView.tsx create mode 100644 components/DynamicIsland/IslandWrapper.tsx create mode 100644 components/DynamicIsland/README.md create mode 100644 components/DynamicIsland/index.ts create mode 100644 components/DynamicIsland/types.ts create mode 100644 components/DynamicIsland/useTutorial.ts create mode 100644 components/DynamicIsland/utils.ts create mode 100644 components/ui/skeleton.tsx create mode 100644 lib/config.ts create mode 100644 lib/kv-store.ts create mode 100644 lib/tutorial/all-tutorials-reader.ts create mode 100644 lib/tutorial/index.ts create mode 100644 lib/tutorial/state-manager.ts create mode 100644 lib/tutorial/tutorial-reader.ts create mode 100644 lib/tutorial/types.ts create mode 100644 lib/validation/middleware.ts diff --git a/.cursor/rules/overview.mdc b/.cursor/rules/overview.mdc index 51937d1b..4a8925ac 100644 --- a/.cursor/rules/overview.mdc +++ b/.cursor/rules/overview.mdc @@ -10,4 +10,8 @@ This is the technical documentation for the Agentuity Cloud, which covers: - Examples, tutorials, samples (/content/Examples) - And the different SDKs (/content/SDKs) -This doc app is a NextJS app built on top of Fumadocs (https://fumadocs.vercel.app/docs/ui). \ No newline at end of file +This doc app is a NextJS app built on top of Fumadocs (https://fumadocs.vercel.app/docs/ui). + +This project also contains the agent the powers RAG and Tutorials. Those agents live in `/agent-docs` directory. +To run the agent server locally, go into that directory with `cd agent-docs` and then start the agent with `agentuity dev`. +To run the NextJS app, do `npm run dev` in the root of this repository. You will want to start these two apps in separate repositories. \ No newline at end of file diff --git a/.cursor/rules/tutorials-structure.mdc b/.cursor/rules/tutorials-structure.mdc new file mode 100644 index 00000000..bf534346 --- /dev/null +++ b/.cursor/rules/tutorials-structure.mdc @@ -0,0 +1,69 @@ +### Rule: tutorials-structure + +Purpose: Define how tutorials are authored and rendered so docs and runnable code stay in sync. + +### Structure +- Narrative pages: `content/Tutorial//step-.mdx` +- Section navigation: `content/Tutorial/meta.json` (list MDX page slugs in order) +- Runnable example project: `examples//` (full project; exclude `node_modules/`, `.uv/`) +- Tutorial code snippets: must be imported from files via `` +- Other, non-project examples: use regular fenced code blocks in MDX + +### MDX authoring +- Each step MDX must use frontmatter: + - `title`, `description` (recommended: `stepNumber`, `next`, `prev`) +- Import real code from the example project using repo-root-relative paths via ``: + +```mdx +--- +title: Step 1 — Getting Started +description: Intro step +--- + + + + + + + + + +``` + +### `` component +- Available in MDX via the docs page components map +- Props: + - `snippets`: array of `{ path, lang?, from?, to?, title? }` + - `path`: repo-root-relative (must start with `/`), validated and read server-side + - `lang`: language for highlighting (auto-inferred if omitted) + - `from`, `to`: 1-based line range (inclusive) + - `title`: label per tab + - `title` (optional): heading displayed above the tabs +- Renders using shared `CodeBlock` styling and `Tabs` for multiple snippets + +### Example project conventions (`examples//`) +- Include: `package.json`, `tsconfig.json`, `src/**`, optional `README.md`, optional lockfile +- Exclude: `node_modules/` +- Optional hygiene: + - Add `examples/**` to `.eslintignore` if you don’t want repo-wide linting on example sources + - Add `examples/**` to root `tsconfig.json` `exclude` if you don’t want repo-wide type-checks +- Should be runnable in a sandbox (StackBlitz/CodeSandbox) via `package.json` scripts + +### Rendering pipeline +- Fumadocs loads MDX via `lib/source.ts` and `app/(docs)/[[...slug]]/page.tsx` +- `` reads files at build/server time and renders with `CodeBlock` + + +### Agent compatibility (optional) +- Step API: `GET /api/tutorials/:id/steps/:stepNumber` returns `{ mdx, snippets }`. +- The chat UI replaces each `` occurrence with one or more fenced code blocks by consuming entries from `snippets` in order. + +### Quality gates (recommended) +- CI verifies: + - All `content/Tutorial/**.mdx` pages referenced by `content/Tutorial/meta.json` build without errors + - All `` references resolve to existing files + - Optional: the example project type-checks (`tsc --noEmit`) or passes a smoke test + diff --git a/.env.example b/.env.example index 9f33510c..fc86baf2 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # Agent Configuration AGENT_BASE_URL=http://127.0.0.1:3500 -AGENT_ID=agent_9ccc5545e93644bd9d7954e632a55a61 +AGENT_QA_ID=agent_9ccc5545e93644bd9d7954e632a55a61 +AGENT_PULSE_ID=agent_ddcb59aa4473f1323be5d9f5fb62b74e -# Alternative: You can also set the full URL instead of BASE_URL + ID -# AGENT_FULL_URL=http://127.0.0.1:3500/agent_9ccc5545e93644bd9d7954e632a55a61 \ No newline at end of file +# API key can be found in agent-docs .env AGENTUITY_SDK_KEY +AGENTUITY_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 38bf69a4..6c466111 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ yarn-error.log* .env*.local .env.local .env.production +.env .vercel next-env.d.ts .open-next diff --git a/agent-docs/agentuity.yaml b/agent-docs/agentuity.yaml index 89d8f17b..e4b99487 100644 --- a/agent-docs/agentuity.yaml +++ b/agent-docs/agentuity.yaml @@ -78,3 +78,6 @@ agents: - id: agent_9ccc5545e93644bd9d7954e632a55a61 name: doc-qa description: Agent that can answer questions based on dev docs as the knowledge base + - id: agent_ddcb59aa4473f1323be5d9f5fb62b74e + name: agent-pulse + description: Agentuity web app agent that converses with users for generate conversation and structured docs tutorials. diff --git a/agent-docs/package.json b/agent-docs/package.json index b1e59c26..9bc7f850 100644 --- a/agent-docs/package.json +++ b/agent-docs/package.json @@ -40,4 +40,4 @@ } }, "module": "index.ts" -} +} \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/README.md b/agent-docs/src/agents/agent-pulse/README.md new file mode 100644 index 00000000..8ff25f7c --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/README.md @@ -0,0 +1,102 @@ +# Pulse Agent + +A conversational AI agent for tutorial management built with OpenAI and structured responses. + +## Overview + +Pulse is a friendly AI assistant that helps users discover, start, and navigate through tutorials. It uses OpenAI's GPT-4o-mini with structured response generation to provide both conversational responses and actionable instructions. + +## Architecture + +### Core Components + +- **`index.ts`**: Main agent logic using `generateObject` for structured responses +- **`chat-helpers.ts`**: Conversation history management +- **`tutorial-helpers.ts`**: Tutorial content fetching and formatting +- **`tutorial.ts`**: Tutorial API integration + +### Response Structure + +The agent uses `generateObject` to return structured responses with two parts: + +```typescript +{ + message: string, // Conversational response for the user + actionable?: { // Optional action for the program to execute + type: 'start_tutorial' | 'next_step' | 'previous_step' | 'get_tutorials' | 'none', + tutorialId?: string, + step?: number + } +} +``` + +### How It Works + +1. **User Input**: Agent receives user message and conversation history +2. **LLM Processing**: OpenAI generates structured response with message and optional actionable object +3. **Action Execution**: Program intercepts actionable objects and executes them: + - `get_tutorials`: Fetches available tutorial list + - `start_tutorial`: Fetches real tutorial content from API + - `next_step`/`previous_step`: Navigate through tutorial steps (TODO) +4. **Response**: Returns conversational message plus any additional data (tutorial content, tutorial list, etc.) + +## Key Features + +- **Structured Responses**: Clean separation between conversation and actions +- **Real Tutorial Content**: No hallucinated content - all tutorial data comes from actual APIs +- **Context Awareness**: Maintains conversation history for natural references +- **Extensible Actions**: Easy to add new action types (next step, hints, etc.) +- **Debug Logging**: Comprehensive logging for troubleshooting + +## Example Interactions + +### Starting a Tutorial +**User**: "I want to learn the JavaScript SDK" + +**LLM Response**: +```json +{ + "message": "I'd be happy to help you start the JavaScript SDK tutorial!", + "actionable": { + "type": "start_tutorial", + "tutorialId": "javascript-sdk" + } +} +``` + +**Final Response**: +```json +{ + "response": "I'd be happy to help you start the JavaScript SDK tutorial!", + "tutorialData": { + "type": "tutorial_step", + "tutorialId": "javascript-sdk", + "tutorialTitle": "JavaScript SDK Tutorial", + "currentStep": 1, + "stepContent": "Welcome to the JavaScript SDK tutorial...", + "codeBlock": {...} + }, + "conversationHistory": [...] +} +``` + +### General Conversation +**User**: "What's the difference between TypeScript and JavaScript?" + +**LLM Response**: +```json +{ + "message": "TypeScript is a superset of JavaScript that adds static type checking...", + "actionable": { + "type": "none" + } +} +``` + +## Benefits + +- **Reliable**: No parsing or tool interception needed +- **Extensible**: Easy to add new action types +- **Clean**: Clear separation between conversation and actions +- **Debuggable**: Can see exactly what the LLM wants to do +- **No Hallucination**: Tutorial content comes from real APIs, not LLM generation diff --git a/agent-docs/src/agents/agent-pulse/context/builder.ts b/agent-docs/src/agents/agent-pulse/context/builder.ts new file mode 100644 index 00000000..ca48a4f1 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/context/builder.ts @@ -0,0 +1,54 @@ +import type { AgentContext } from "@agentuity/sdk"; + +export async function buildSystemPrompt(tutorialContext: string, ctx: AgentContext): Promise { + try { + const systemPrompt = `=== ROLE === +You are Pulse, an AI assistant designed to help developers learn and navigate the Agentuity platform through interactive tutorials and clear guidance. Your primary goal is to assist users with understanding and using the Agentuity SDK effectively. When a user's query is vague, unclear, or lacks specific intent, subtly suggest relevant interactive tutorial to guide them toward learning the platform. For clear, specific questions related to the Agentuity SDK or other topics, provide direct, accurate, and concise answers without mentioning tutorials unless relevant. Always maintain a friendly and approachable tone to encourage engagement. + +Your role is to ensure user have a smooth tutorial experience! + +When user is asking to move to the next tutorial, simply increment the step for them. + +=== PERSONALITY === +- Friendly and encouraging with light humour +- Patient with learners at all levels +- Clear and concise in explanations +- Enthusiastic about teaching and problem-solving + +=== Available Tools or Functions === +You have access to various tools you can use -- use when appropriate! +1. Tutorial management + - startTutorialAtStep: Starting the user off at a specific step of a tutorial. +2. General assistance + - askDocsAgentTool: retrieve Agentuity documentation snippets + +=== TOOL-USAGE RULES (must follow) === +- startTutorialById must only be used when user select a tutorial. If the user starts a new tutorial, the step number should be set to one. Valid step is between 1 and totalSteps of the specific tutorial. +- Treat askDocsAgentTool as a search helper; ignore results you judge irrelevant. + +=== RESPONSE STYLE (format guidelines) === +- Begin with a short answer, then elaborate if necessary. +- Add brief comments to complex code; skip obvious lines. +- End with a question when further clarification could help the user. + +=== SAFETY & BOUNDARIES === +- If asked for private data or secrets, refuse. +- If the user requests actions outside your capabilities, apologise and explain. +- Keep every response < 400 words + +Generate a response to the user query accordingly and try to be helpful + +=== CONTEXT === +${tutorialContext} + +=== END OF PROMPT === + +Stream your reasoning steps clearly.`; + + ctx.logger.debug("Built system prompt with tutorial context"); + return systemPrompt; + } catch (error) { + ctx.logger.error("Failed to build system prompt: %s", error instanceof Error ? error.message : String(error)); + throw error; // Re-throw for centralized handling + } +} \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/index.ts b/agent-docs/src/agents/agent-pulse/index.ts new file mode 100644 index 00000000..5932c5fc --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/index.ts @@ -0,0 +1,143 @@ +import type { AgentRequest, AgentResponse, AgentContext } from "@agentuity/sdk"; +import { streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { createTools } from "./tools"; +import { createAgentState } from "./state"; +import { getTutorialList, type Tutorial } from "./tutorial"; +import { parseAgentRequest } from "./request/parser"; +import { buildSystemPrompt } from "./context/builder"; +import { createStreamingProcessor } from "./streaming/processor"; +import type { ConversationMessage, TutorialState } from "./request/types"; + +/** + * Builds a context string containing available tutorials for the system prompt + */ +async function buildContext( + ctx: AgentContext, + tutorialState?: TutorialState +): Promise { + try { + const tutorials = await getTutorialList(ctx); + + // Handle API failure early + if (!tutorials.success || !tutorials.data) { + ctx.logger.warn("Failed to load tutorial list"); + return defaultFallbackContext(); + } + + const tutorialContent = JSON.stringify(tutorials.data, null, 2); + const currentTutorialInfo = buildCurrentTutorialInfo( + tutorials.data, + tutorialState + ); + + return `===AVAILABLE TUTORIALS==== + + ${tutorialContent} + + ${currentTutorialInfo} + + Note: You should not expose the details of the tutorial IDs to the user. +`; + } catch (error) { + ctx.logger.error("Error building tutorial context: %s", error); + return defaultFallbackContext(); + } +} + +/** + * Builds current tutorial information string if user is in a tutorial + */ +function buildCurrentTutorialInfo( + tutorials: Tutorial[], + tutorialState?: TutorialState +): string { + if (!tutorialState?.tutorialId) { + return ""; + } + + const currentTutorial = tutorials.find( + (t) => t.id === tutorialState.tutorialId + ); + if (!currentTutorial) { + return "\nWarning: User appears to be in an unknown tutorial."; + } + if (tutorialState.currentStep > currentTutorial.totalSteps) { + return `\nUser has completed the tutorial: ${currentTutorial.title} (${currentTutorial.totalSteps} steps)`; + } + return `\nUser is currently on this tutorial: ${currentTutorial.title} (Step ${tutorialState.currentStep} of ${currentTutorial.totalSteps})`; +} + +/** + * Returns fallback context when tutorial list can't be loaded + */ +function defaultFallbackContext(): string { + return `===AVAILABLE TUTORIALS==== +Unable to load tutorial list. Please try again later or contact support.`; +} + +export default async function Agent( + req: AgentRequest, + resp: AgentResponse, + ctx: AgentContext +) { + try { + const parsedRequest = parseAgentRequest(await req.data.json(), ctx); + + // Create state manager + const state = createAgentState(); + + // Build messages for the conversation + const messages: ConversationMessage[] = [ + ...parsedRequest.conversationHistory, + { author: "USER", content: parsedRequest.message }, + ]; + + let tools: any; + let systemPrompt: string = ""; + // Direct LLM access won't require any tools or system prompt + if (!parsedRequest.useDirectLLM) { + // Create tools with state context + tools = await createTools({ + state, + agentContext: ctx, + }); + + // Build tutorial context and system prompt + const tutorialContext = await buildContext( + ctx, + parsedRequest.tutorialData + ); + systemPrompt = await buildSystemPrompt(tutorialContext, ctx); + } + + // Generate streaming response + const result = await streamText({ + model: openai("gpt-4o"), + messages: messages.map((msg) => ({ + role: msg.author === "USER" ? "user" : "assistant", + content: msg.content, + })), + tools, + maxSteps: 3, + system: systemPrompt, + }); + + // Create and return streaming response + const stream = createStreamingProcessor(result, state, ctx); + return resp.stream(stream, "text/event-stream"); + } catch (error) { + ctx.logger.error( + "Agent request failed: %s", + error instanceof Error ? error.message : String(error) + ); + return resp.json( + { + error: + "Sorry, I encountered an error while processing your request. Please try again.", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/agent-docs/src/agents/agent-pulse/request/parser.ts b/agent-docs/src/agents/agent-pulse/request/parser.ts new file mode 100644 index 00000000..91c5b254 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/request/parser.ts @@ -0,0 +1,49 @@ +import type { AgentContext } from "@agentuity/sdk"; +import type { ParsedAgentRequest } from "./types"; + +export function parseAgentRequest( + jsonData: any, + ctx: AgentContext +): ParsedAgentRequest { + try { + let message: string = ""; + let conversationHistory: any[] = []; + let tutorialData: any = undefined; + let useDirectLLM = false; + + if (jsonData && typeof jsonData === "object" && !Array.isArray(jsonData)) { + const body = jsonData as any; + message = body.message || ""; + useDirectLLM = body.use_direct_llm || false; + // Process conversation history + if (Array.isArray(body.conversationHistory)) { + conversationHistory = body.conversationHistory.map((msg: any) => { + // Extract only role and content + return { + role: msg.role || (msg.author ? msg.author.toUpperCase() : "USER"), + content: msg.content || "", + }; + }); + } + + tutorialData = body.tutorialData || undefined; + } else { + // Fallback for non-object data + message = String(jsonData || ""); + } + + return { + message, + conversationHistory, + tutorialData, + useDirectLLM, + }; + } catch (error) { + ctx.logger.error( + "Failed to parse agent request: %s", + error instanceof Error ? error.message : String(error) + ); + ctx.logger.debug("Raw request data: %s", JSON.stringify(jsonData)); + throw error; // Re-throw for centralized handling + } +} diff --git a/agent-docs/src/agents/agent-pulse/request/types.ts b/agent-docs/src/agents/agent-pulse/request/types.ts new file mode 100644 index 00000000..3f14e830 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/request/types.ts @@ -0,0 +1,16 @@ +export interface ConversationMessage { + author: "USER" | "ASSISTANT"; + content: string; +} + +export interface TutorialState { + tutorialId: string; + currentStep: number; +} + +export interface ParsedAgentRequest { + message: string; + conversationHistory: ConversationMessage[]; + tutorialData?: TutorialState; + useDirectLLM?: boolean; +} \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/state.ts b/agent-docs/src/agents/agent-pulse/state.ts new file mode 100644 index 00000000..2cc4bc83 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/state.ts @@ -0,0 +1,46 @@ +enum ActionType { + START_TUTORIAL_STEP = "start_tutorial_step" +} + +interface Action { + type: ActionType; + tutorialId: string; + currentStep: number; + totalSteps: number; +} + +interface AgentState { + action: Action | null; + + setAction(action: Action): void; + getAction(): Action | null; + clearAction(): void; + hasAction(): boolean; +} + +class SimpleAgentState implements AgentState { + public action: Action | null = null; + + setAction(action: Action): void { + this.action = action; + } + + getAction(): Action | null { + return this.action; + } + + clearAction(): void { + this.action = null; + } + + hasAction(): boolean { + return this.action !== null; + } +} + +export function createAgentState(): AgentState { + return new SimpleAgentState(); +} + +export type { Action, AgentState }; +export { ActionType }; \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/state/manager.ts b/agent-docs/src/agents/agent-pulse/state/manager.ts new file mode 100644 index 00000000..00c1c570 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/state/manager.ts @@ -0,0 +1,54 @@ +import type { AgentContext } from "@agentuity/sdk"; +import { ActionType, type AgentState } from "../state"; +import { getTutorialStep } from "../tutorial"; +import type { TutorialData } from "../streaming/types"; + +export async function handleTutorialState( + state: AgentState, + ctx: AgentContext +): Promise { + try { + if (!state.hasAction()) { + return null; + } + + const action = state.getAction(); + if (!action) { + ctx.logger.warn("No action found in state"); + return null; + } + + ctx.logger.info("Processing action: %s", JSON.stringify(action, null, 2)); + + switch (action.type) { + case ActionType.START_TUTORIAL_STEP: + if (action.tutorialId) { + const tutorialStep = await getTutorialStep(action.tutorialId, action.currentStep, ctx); + if (tutorialStep.success && tutorialStep.data) { + const tutorialData: TutorialData = { + tutorialId: action.tutorialId, + totalSteps: action.totalSteps, + currentStep: action.currentStep, + tutorialStep: { + title: (tutorialStep.data.meta?.title as string) || tutorialStep.data.slug, + mdx: tutorialStep.data.mdx, + snippets: tutorialStep.data.snippets, + totalSteps: action.totalSteps + } + }; + state.clearAction(); + ctx.logger.info("Tutorial state processed successfully"); + return tutorialData; + } + } + break; + default: + ctx.logger.warn("Unknown action type: %s", action.type); + } + + return null; + } catch (error) { + ctx.logger.error("Failed to handle tutorial state: %s", error instanceof Error ? error.message : String(error)); + throw error; // Re-throw for centralized handling + } +} \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/streaming/processor.ts b/agent-docs/src/agents/agent-pulse/streaming/processor.ts new file mode 100644 index 00000000..72f89019 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/streaming/processor.ts @@ -0,0 +1,115 @@ +import type { AgentContext } from "@agentuity/sdk"; +import type { AgentState } from "../state"; +import type { StreamingChunk, TutorialData } from "./types"; +import { handleTutorialState } from "../state/manager"; + +export function createStreamingProcessor( + result: any, + state: AgentState, + ctx: AgentContext +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + let accumulatedContent = ""; + + try { + // Stream only safe, user-facing content + for await (const chunk of result.fullStream) { + await processChunk( + chunk, + controller, + encoder, + ctx, + accumulatedContent + ); + } + + // Process tutorial state after streaming text + const finalTutorialData = await handleTutorialState(state, ctx); + + // Send tutorial data if available + if (finalTutorialData) { + sendChunk(controller, encoder, { + type: "tutorial-data", + tutorialData: finalTutorialData, + }); + } + + // Send finish signal + sendChunk(controller, encoder, { type: "finish" }); + + controller.close(); + } catch (error) { + ctx.logger.error( + "Error in streaming response: %s", + error instanceof Error ? error.message : String(error) + ); + sendChunk(controller, encoder, { + type: "error", + error: "Sorry, I encountered an error while processing your request.", + details: error instanceof Error ? error.message : String(error), + }); + controller.close(); + } + }, + }); +} + +async function processChunk( + chunk: any, + controller: ReadableStreamDefaultController, + encoder: TextEncoder, + ctx: AgentContext, + accumulatedContent: string +): Promise { + try { + if (chunk.type === "text-delta") { + accumulatedContent += chunk.textDelta; + sendChunk(controller, encoder, { + type: "text-delta", + textDelta: chunk.textDelta, + }); + } else if (chunk.type === "tool-call") { + const toolName = chunk.toolName || "tool"; + const userFriendlyMessage = getToolStatusMessage(toolName); + sendChunk(controller, encoder, { + type: "status", + message: userFriendlyMessage, + category: "tool", + }); + ctx.logger.debug("Tool called: %s", toolName); + } else if (chunk.type === "reasoning") { + ctx.logger.debug("REASONING: %s", chunk); + } else { + ctx.logger.debug("Skipping chunk type: %s", chunk.type); + ctx.logger.debug(chunk); + } + } catch (error) { + ctx.logger.error( + "Failed to process chunk: %s", + error instanceof Error ? error.message : String(error) + ); + ctx.logger.debug("Chunk data: %s", JSON.stringify(chunk)); + throw error; // Re-throw for centralized handling + } +} + +function sendChunk( + controller: ReadableStreamDefaultController, + encoder: TextEncoder, + chunk: StreamingChunk +): void { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); +} + +function getToolStatusMessage(toolName: string): string { + switch (toolName) { + case "startTutorialById": + return "Starting tutorial..."; + case "queryOtherAgent": + return "Searching documentation..."; + default: + return "Processing your request..."; + } +} diff --git a/agent-docs/src/agents/agent-pulse/streaming/types.ts b/agent-docs/src/agents/agent-pulse/streaming/types.ts new file mode 100644 index 00000000..5fcaad52 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/streaming/types.ts @@ -0,0 +1,48 @@ +export interface TextDeltaChunk { + type: 'text-delta'; + textDelta: string; +} + +export interface StatusChunk { + type: 'status'; + message: string; + category?: 'tool' | 'search' | 'processing'; +} + +export interface TutorialSnippet { + path: string; + lang?: string; + from?: number; + to?: number; + title?: string; + content: string; +} + +export interface TutorialData { + tutorialId: string; + totalSteps: number; + currentStep: number; + tutorialStep: { + title: string; + mdx: string; + snippets: TutorialSnippet[]; + totalSteps: number; + }; +} + +export interface TutorialDataChunk { + type: 'tutorial-data'; + tutorialData: TutorialData; +} + +export interface ErrorChunk { + type: 'error'; + error: string; + details?: string; +} + +export interface FinishChunk { + type: 'finish'; +} + +export type StreamingChunk = TextDeltaChunk | StatusChunk | TutorialDataChunk | ErrorChunk | FinishChunk; \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/tools.ts b/agent-docs/src/agents/agent-pulse/tools.ts new file mode 100644 index 00000000..a53b20d5 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/tools.ts @@ -0,0 +1,103 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { ActionType } from "./state"; +import type { AgentState } from "./state"; +import type { AgentContext } from "@agentuity/sdk"; +import { getTutorialMeta } from "./tutorial"; + +/** + * Context passed to tools for state management and logging + */ +interface ToolContext { + state: AgentState; + agentContext: AgentContext; +} + +/** + * Factory function that creates tools with state management context + */ +export async function createTools(context: ToolContext) { + const { state, agentContext } = context; + const DOC_QA_AGENT_NAME = "doc-qa"; + const docQaAgent = await agentContext.getAgent({ name: DOC_QA_AGENT_NAME }); + /** + * Tool for starting a tutorial - adds action to state queue + */ + const startTutorialAtStep = tool({ + description: "Start a specific tutorial for the user. You must call this function in order for the user to see the tutorial step content. The step number should be between 1 and the total number of steps in the tutorial.", + parameters: z.object({ + tutorialId: z.string().describe("The exact ID of the tutorial to start"), + stepNumber: z.number().describe("The step number of the tutorial to start (1 to total available steps in the tutorial)") + }), + execute: async ({ tutorialId, stepNumber }) => { + // Validate tutorial exists before starting + const tutorialResponse = await getTutorialMeta(tutorialId, agentContext); + if (!tutorialResponse.success || !tutorialResponse.data) { + return `Error fetching tutorial information`; + } + + const data = tutorialResponse.data + const totalSteps = tutorialResponse.data.totalSteps; + if (stepNumber > totalSteps) { + return `This tutorial only has ${totalSteps} steps. You either reached the end of the tutorial or selected an incorrect step number.`; + } + state.setAction({ + type: ActionType.START_TUTORIAL_STEP, + tutorialId: tutorialId, + currentStep: stepNumber, + totalSteps: tutorialResponse.data.totalSteps + }); + agentContext.logger.info("Added start_tutorial action to state for: %s at step %d", tutorialId, stepNumber); + return `Starting "${data.title}". Total steps: ${data.totalSteps} \n\n Description: ${data.description}`; + }, + }); + + /** + * Tool for talking to other agents (nong-tutorial functionality) + * This tool doesn't use state - it returns data directly + */ + const askDocsAgentTool = tool({ + description: "Query the Agentuity Development Documentation agent using RAG (Retrieval Augmented Generation) to get relevant documentation and answers about the Agentuity platform, APIs, and development concepts", + parameters: z.object({ + query: z.string().describe("The question or query to send to the query function"), + }), + execute: async ({ query }) => { + agentContext.logger.info("Querying agent %s with: %s", DOC_QA_AGENT_NAME, query); + const agentPayload = { + message: query, + + } + const response = await docQaAgent.run({ + data: agentPayload, + contentType: 'application/json' + }) + // TODO: handle the docs referencing and inject it to the frontend response + const responseData = await response.data.json(); + return responseData; + }, + }); + + /** + * TODO: This tool allow the agent to get details information about the code execution that the user performed. + */ + const fetchCodeExecutionResultTool = tool({ + description: "Fetch code execution results from the frontend", + parameters: z.object({ + executionId: z.string().describe("The ID of the code execution"), + }), + execute: async ({ executionId }) => { + agentContext.logger.info("Fetching execution result for: %s", executionId); + // This would actually fetch execution results + // For now, just return a mock response + return `Result for execution ${executionId}: console.log('Hello, World!');\n// Output: Hello, World!`; + }, + }); + + // Return tools object + return { + startTutorialById: startTutorialAtStep, + queryOtherAgent: askDocsAgentTool, + }; +} + +export type { ToolContext }; \ No newline at end of file diff --git a/agent-docs/src/agents/agent-pulse/tutorial.ts b/agent-docs/src/agents/agent-pulse/tutorial.ts new file mode 100644 index 00000000..c5a00ed1 --- /dev/null +++ b/agent-docs/src/agents/agent-pulse/tutorial.ts @@ -0,0 +1,98 @@ +import type { AgentContext } from '@agentuity/sdk'; + +const TUTORIAL_API_BASE_URL = process.env.TUTORIAL_API_URL; + +export interface Tutorial { + id: string; + title: string; + description: string; + totalSteps: number; +} + +interface ApiResponse { + success: boolean; + data?: T; + error?: string; + status?: number; +} + +export interface TutorialSnippet { + path: string; + lang?: string; + from?: number; + to?: number; + title?: string; + content: string; +} + +interface TutorialStepResponseData { + tutorialId: string; + stepNumber: number; + slug: string; + meta: Record; + mdx: string; + snippets: TutorialSnippet[]; +} + +export async function getTutorialList(ctx: AgentContext): Promise> { + try { + const response = await fetch(`${TUTORIAL_API_BASE_URL}/api/tutorials`); + + if (!response.ok) { + const error = await response.json(); + ctx.logger.error('Tutorial API error: %s', error); + throw new Error(`Tutorial API error: ${JSON.stringify(error)}`); + } + + const tutorials = await response.json() as Tutorial[]; + ctx.logger.info('Fetched %d tutorials', tutorials.length); + + return { + success: true, + data: tutorials + }; + } catch (error) { + ctx.logger.error('Error fetching tutorial list: %s', error); + throw error; + } +} + +export async function getTutorialMeta(tutorialId: string, ctx: AgentContext): Promise> { + try { + // New behavior: fetch all, then find by id + const response = await fetch(`${TUTORIAL_API_BASE_URL}/api/tutorials`); + if (!response.ok) { + const error = `Failed to fetch tutorial list: ${response.statusText}`; + ctx.logger.error(error); + return { success: false, status: response.status, error: response.statusText }; + } + const tutorials = (await response.json()) as Tutorial[]; + const found = tutorials.find(t => t.id === tutorialId); + if (!found) { + return { success: false, status: 404, error: 'Tutorial not found' }; + } + return { success: true, data: found }; + } catch (error) { + ctx.logger.error('Error fetching tutorial metadata for %s: %s', tutorialId, error); + throw error; + } +} + +export async function getTutorialStep(tutorialId: string, stepNumber: number, ctx: AgentContext): Promise> { + try { + const response = await fetch(`${TUTORIAL_API_BASE_URL}/api/tutorials/${tutorialId}/steps/${stepNumber}`); + + if (!response.ok) { + ctx.logger.error('Failed to fetch tutorial step %d for tutorial %s: %s', stepNumber, tutorialId, response.statusText); + return { success: false, status: response.status, error: response.statusText }; + } + + const responseData = await response.json(); + ctx.logger.info('Fetched step %d for tutorial %s', stepNumber, tutorialId); + + return responseData as ApiResponse; + } catch (error) { + ctx.logger.error('Error fetching tutorial step %d for tutorial %s: %s', stepNumber, tutorialId, error); + throw error; + } +} \ No newline at end of file diff --git a/agent-docs/src/agents/doc-qa/prompt.ts b/agent-docs/src/agents/doc-qa/prompt.ts index cec823fc..24aa5f01 100644 --- a/agent-docs/src/agents/doc-qa/prompt.ts +++ b/agent-docs/src/agents/doc-qa/prompt.ts @@ -55,7 +55,6 @@ Return ONLY the query text, nothing else.`; }); const rephrasedQuery = result.text?.trim() || input; - console.log(rephrasedQuery); // Log if we actually rephrased it if (rephrasedQuery !== input) { ctx.logger.info( diff --git a/agent-docs/src/agents/doc-qa/rag.ts b/agent-docs/src/agents/doc-qa/rag.ts index 3140fd26..5e13215e 100644 --- a/agent-docs/src/agents/doc-qa/rag.ts +++ b/agent-docs/src/agents/doc-qa/rag.ts @@ -25,8 +25,7 @@ Your role is to be as helpful as possible and try to assist user by answering th === RULES === 1. Use ONLY the content inside tags to craft your reply. If the required information is missing, state that the docs do not cover it. 2. Never fabricate or guess undocumented details. -3. Focus on answering the QUESTION with the available provided to you. Keep in mind some might not be relevant, - so pick the ones that is relevant to the user's question. +3. Focus on answering the QUESTION with the available provided to you. Keep in mind some might not be relevant, so pick the ones that is relevant to the user's question. 4. Ambiguity handling: • When contains more than one distinct workflow or context that could satisfy the question, do **not** choose for the user. • Briefly (≤ 2 sentences each) summarise each plausible interpretation and ask **one** clarifying question so the user can pick a path. @@ -41,8 +40,8 @@ Your role is to be as helpful as possible and try to assist user by answering th • Use **bold** for important terms and *italic* for emphasis when appropriate. • Use > blockquotes for important notes or warnings. 6. You may suggest concise follow-up questions or related topics that are present in . -7. Keep a neutral, factual tone. - +7. If do not answer the question, state that explicitly and offer the closest documented topic; answer strictly from or ask one clarifying question if nothing related exists. +8. Keep a neutral, factual tone. === OUTPUT FORMAT === Return **valid JSON only** matching this TypeScript type: diff --git a/app/(docs)/[[...slug]]/page.tsx b/app/(docs)/[[...slug]]/page.tsx index 1e04170f..653b12ba 100644 --- a/app/(docs)/[[...slug]]/page.tsx +++ b/app/(docs)/[[...slug]]/page.tsx @@ -19,6 +19,7 @@ import { source } from '@/lib/source'; import { CommunityButton } from '../../../components/Community'; import CopyPageDropdown from '../../../components/CopyPageDropdown'; import { NavButton } from '../../../components/NavButton'; +import CodeFromFiles from '../../../components/CodeFromFiles'; export default async function Page(props: { params: Promise<{ slug?: string[] }>; @@ -57,6 +58,7 @@ export default async function Page(props: { PopupTrigger, CodeExample, CLICommand, + CodeFromFiles, CommunityButton, Mermaid, NavButton, diff --git a/app/api/sessions/[sessionId]/messages/route.ts b/app/api/sessions/[sessionId]/messages/route.ts new file mode 100644 index 00000000..523b43d1 --- /dev/null +++ b/app/api/sessions/[sessionId]/messages/route.ts @@ -0,0 +1,374 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getKVValue, setKVValue } from "@/lib/kv-store"; +import { + Session, + Message, + StreamingChunk, + TutorialData, +} from "@/app/chat/types"; +import { toISOString, getCurrentTimestamp } from "@/app/chat/utils/dateUtils"; +import { getAgentPulseConfig } from "@/lib/env"; +import { config } from "@/lib/config"; +import { parseAndValidateJSON, SessionMessageRequestSchema } from "@/lib/validation/middleware"; + +// Constants +const DEFAULT_CONVERSATION_HISTORY_LIMIT = 10; +const AGENT_REQUEST_TIMEOUT = 30000; // 30 seconds + + +function sanitizeTitle(input: string): string { + if (!input) return ''; + let s = input.trim(); + // Strip wrapping quotes/backticks + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith('\'') && s.endsWith('\'')) || (s.startsWith('`') && s.endsWith('`'))) { + s = s.slice(1, -1).trim(); + } + // Remove markdown emphasis + s = s.replace(/\*\*([^*]+)\*\*|\*([^*]+)\*|__([^_]+)__|_([^_]+)_/g, (_m, a, b, c, d) => a || b || c || d || ''); + // Remove emojis (basic unicode emoji ranges) + s = s.replace(/[\u{1F300}-\u{1FAFF}\u{1F900}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu, ''); + // Collapse whitespace + s = s.replace(/\s+/g, ' ').trim(); + // Sentence case + s = sentenceCase(s); + // Trim trailing punctuation noise + s = s.replace(/[\s\-–—:;,\.]+$/g, '').trim(); + // Enforce 60 chars + if (s.length > 60) s = s.slice(0, 60).trim(); + return s; +} + +function sentenceCase(str: string): string { + if (!str) return ''; + const lower = str.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +/** + * POST /api/sessions/[sessionId]/messages - Add a message to a session and process with streaming + * + * This endpoint now handles: + * 1. Adding a user message to a session + * 2. Processing the message with the agent + * 3. Streaming the response back to the client + * 4. Saving the assistant's response when complete + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const userId = request.cookies.get("chat_user_id")?.value; + if (!userId) { + return NextResponse.json({ error: "User ID not found" }, { status: 401 }); + } + + const paramsData = await params; + const sessionId = paramsData.sessionId; + + const validation = await parseAndValidateJSON(request, SessionMessageRequestSchema); + + if (!validation.success) { + return validation.response; + } + + const { message, processWithAgent } = validation.data; + + // Ensure timestamp is in ISO string format + if (message.timestamp) { + message.timestamp = toISOString(message.timestamp); + } + const sessionKey = `${userId}_${sessionId}`; + const sessionResponse = await getKVValue(sessionKey, { + storeName: config.defaultStoreName, + }); + + // Helper: background title generation and persistence + async function generateAndPersistTitle(sessionId: string, sessionKey: string, finalSession: Session) { + try { + if ((finalSession as any).title) { + return; // Title already set + } + // Build compact conversation history (last 10 messages, truncate content) + const HISTORY_LIMIT = 10; + const MAX_CONTENT_LEN = 400; + const history = finalSession.messages + .slice(-HISTORY_LIMIT) + .map(m => ({ + author: m.author, + content: (m.content || '').slice(0, MAX_CONTENT_LEN), + })); + + const prompt = `Generate a very short session title summarizing the conversation topic.\n\nRequirements:\n- sentence case\n- no emojis\n- <= 60 characters\n- no quotes or markdown\n- output the title only, no extra text`; + + const agentConfig = getAgentPulseConfig(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (agentConfig.bearerToken) headers['Authorization'] = `Bearer ${agentConfig.bearerToken}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + let agentResponse: Response | null = null; + try { + agentResponse = await fetch(agentConfig.url, { + method: 'POST', + headers, + body: JSON.stringify({ + message: prompt, + conversationHistory: history, + use_direct_llm: true, + }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (!agentResponse || !agentResponse.ok) { + console.error(`[title-gen] failed: bad response ${agentResponse ? agentResponse.status : 'no-response'}`); + return; + } + + const reader = agentResponse.body?.getReader(); + if (!reader) { + console.error('[title-gen] failed: no response body'); + return; + } + + let accumulated = ''; + const textDecoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + const text = textDecoder.decode(value); + for (const line of text.split('\n')) { + if (line.startsWith('data: ')) { + try { + const ev = JSON.parse(line.slice(6)); + if (ev.type === 'text-delta' && ev.textDelta) accumulated += ev.textDelta; + if (ev.type === 'finish') { + try { await reader.cancel(); } catch { } + break; + } + } catch { } + } + } + } + } + + const candidate = sanitizeTitle(accumulated); + const title = candidate || 'New chat'; + + // Re-fetch and set title only if still empty + const latest = await getKVValue(sessionKey, { storeName: config.defaultStoreName }); + if (!latest.success || !latest.data) return; + const current = latest.data as any; + if (current.title) return; + current.title = title; + await setKVValue(sessionKey, current, { storeName: config.defaultStoreName }); + + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('The operation was aborted') || msg.includes('aborted')) { + console.error('[title-gen] timeout after 3000ms'); + } else { + console.error(`[title-gen] failed: ${msg}`); + } + } + } + if (!sessionResponse.success || !sessionResponse.data) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + + const session = sessionResponse.data; + + const updatedSession: Session = { + ...session, + messages: [...session.messages, message], + }; + + try { + await setKVValue(sessionKey, updatedSession, { + storeName: config.defaultStoreName, + }); + } catch (error) { + console.error( + `Failed to save session after adding message. SessionId: ${sessionId}, Error details:`, + error instanceof Error ? error.message : String(error), + error instanceof Error && error.stack ? `Stack: ${error.stack}` : '' + ); + return NextResponse.json( + { + error: "Failed to save message to session", + details: "Unable to persist the message. Please try again." + }, + { status: 500 } + ); + } + + if (!processWithAgent || message.author !== "USER") { + return NextResponse.json( + { success: true, session: updatedSession }, + { status: 200 } + ); + } + + // Create assistant message placeholder for tracking + const assistantMessageId = crypto.randomUUID(); + + // Process with agent and stream response + const agentConfig = getAgentPulseConfig(); + const agentUrl = agentConfig.url; + + // Get current tutorial state for the user + const { TutorialStateManager } = await import('@/lib/tutorial/state-manager'); + const currentTutorialState = await TutorialStateManager.getCurrentTutorialState(userId); + + const agentPayload = { + message: message.content, + conversationHistory: updatedSession.messages.slice( + -DEFAULT_CONVERSATION_HISTORY_LIMIT + ), + tutorialData: currentTutorialState, + }; + + // Prepare headers with optional bearer token + const headers: Record = { + "Content-Type": "application/json", + }; + if (agentConfig.bearerToken) { + headers["Authorization"] = `Bearer ${agentConfig.bearerToken}`; + } + + // Real agent call (SSE response expected) + const agentResponse = await fetch(agentUrl, { + method: 'POST', + headers, + body: JSON.stringify(agentPayload), + signal: AbortSignal.timeout(AGENT_REQUEST_TIMEOUT), + }); + + if (!agentResponse.ok) { + throw new Error(`Agent responded with status: ${agentResponse.status}`); + } + + // Process streaming response + let accumulatedContent = ""; + let finalTutorialData: TutorialData | undefined = undefined; + + const transformStream = new TransformStream({ + async transform(chunk, controller) { + // Forward the chunk to the client + controller.enqueue(chunk); + + // Process the chunk to accumulate the full response + const text = new TextDecoder().decode(chunk); + const lines = text.split("\n"); + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.slice(6)) as StreamingChunk; + + if (data.type === "text-delta" && data.textDelta) { + accumulatedContent += data.textDelta; + } else if (data.type === "tutorial-data" && data.tutorialData) { + finalTutorialData = data.tutorialData; + + // Update user's tutorial progress + await TutorialStateManager.updateTutorialProgress( + userId, + finalTutorialData.tutorialId, + finalTutorialData.currentStep, + finalTutorialData.totalSteps + ); + } else if (data.type === "finish") { + // When the stream is finished, save the assistant message + const assistantMessage: Message = { + id: assistantMessageId, + author: "ASSISTANT", + content: accumulatedContent, + timestamp: getCurrentTimestamp(), + tutorialData: finalTutorialData, + }; + + const finalSession = { + ...updatedSession, + messages: [...updatedSession.messages, assistantMessage], + }; + + await setKVValue(sessionKey, finalSession, { + storeName: config.defaultStoreName, + }); + + // Trigger background title generation if missing + // Do not await to avoid delaying the client stream completion + void generateAndPersistTitle(sessionId, sessionKey, finalSession); + + // Send the final session in the finish event + controller.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify({ + type: "finish", + session: finalSession, + })}\n\n` + ) + ); + } + } catch (error) { + console.error("Error processing stream chunk:", error); + } + } + } + }, + }); + + // Pipe the agent response through our transform stream + const reader = agentResponse.body?.getReader(); + if (!reader) { + throw new Error("No response body from agent"); + } + const writer = transformStream.writable.getWriter(); + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + try { + await writer.write(value); + } catch (writeError) { + console.error('Error writing to transform stream:', writeError); + throw writeError; + } + } + await writer.close(); + } catch (error) { + console.error('Error in stream processing:', error); + writer.abort(error); + } + })(); + + return new NextResponse(transformStream.readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } catch (error) { + console.error("Error in messages API:", error); + // Log the full error stack trace for debugging + if (error instanceof Error) { + console.error("Error stack:", error.stack); + } + return new Response( + JSON.stringify({ + error: "Internal server error", + details: error instanceof Error ? error.message : String(error), + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +} diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts new file mode 100644 index 00000000..a3900650 --- /dev/null +++ b/app/api/sessions/[sessionId]/route.ts @@ -0,0 +1,266 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getKVValue, setKVValue, deleteKVValue } from '@/lib/kv-store'; +import { Session, Message, SessionSchema } from '@/app/chat/types'; +import { toISOString } from '@/app/chat/utils/dateUtils'; +import { config } from '@/lib/config'; +import { parseAndValidateJSON, SessionMessageOnlyRequestSchema } from '@/lib/validation/middleware'; + +/** + * GET /api/sessions/[sessionId] - Get a specific session + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const paramsData = await params; + const sessionId = paramsData.sessionId; + const sessionKey = `${userId}_${sessionId}`; + const response = await getKVValue(sessionKey, { storeName: config.defaultStoreName }); + + if (!response.success) { + return NextResponse.json( + { error: response.error || 'Session not found' }, + { status: response.statusCode || 404 } + ); + } + + return NextResponse.json({ session: response.data }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} + +/** + * PUT /api/sessions/[sessionId] - Update a session + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const paramsData = await params; + const sessionId = paramsData.sessionId; + const sessionKey = `${userId}_${sessionId}`; + + const validation = await parseAndValidateJSON(request, SessionSchema); + if (!validation.success) { + return validation.response; + } + + const session = validation.data; + + if (session.sessionId !== sessionId) { + return NextResponse.json( + { error: 'Session ID mismatch' }, + { status: 400 } + ); + } + + // Process any messages to ensure timestamps are in ISO string format + if (session.messages && session.messages.length > 0) { + session.messages = session.messages.map((message: Message) => { + if (message.timestamp) { + return { + ...message, + timestamp: toISOString(message.timestamp) + }; + } + return message; + }); + } + + // Update the individual session + const response = await setKVValue( + sessionKey, + session, + { storeName: config.defaultStoreName } + ); + + if (!response.success) { + return NextResponse.json( + { error: response.error || 'Failed to update session' }, + { status: response.statusCode || 500 } + ); + } + + // Update the master list if needed (ensure the session ID is in the list) + const allSessionsResponse = await getKVValue(userId, { storeName: config.defaultStoreName }); + const sessionIds = allSessionsResponse.success ? allSessionsResponse.data || [] : []; + + // If the session ID isn't in the list, add it to the beginning + if (!sessionIds.includes(sessionKey)) { + const updatedSessionIds = [sessionKey, ...sessionIds]; + + const sessionsListResponse = await setKVValue( + userId, + updatedSessionIds, + { storeName: config.defaultStoreName } + ); + + if (!sessionsListResponse.success) { + // Log the error but don't fail the request + console.error('Failed to update sessions list:', sessionsListResponse.error); + } + } + + return NextResponse.json({ success: true, session }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/sessions/[sessionId] - Delete a session + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const paramsData = await params; + const sessionId = paramsData.sessionId; + const sessionKey = `${userId}_${sessionId}`; + // Delete the session data + const sessionResponse = await deleteKVValue( + sessionKey, + { storeName: config.defaultStoreName } + ); + + if (!sessionResponse.success) { + return NextResponse.json( + { error: sessionResponse.error || 'Failed to delete session' }, + { status: sessionResponse.statusCode || 500 } + ); + } + + // Remove from sessions list + const allSessionsResponse = await getKVValue(userId, { storeName: config.defaultStoreName }); + const sessionIds = allSessionsResponse.success ? allSessionsResponse.data || [] : []; + + const updatedSessionIds = sessionIds.filter(id => id !== sessionKey); + + const sessionsListResponse = await setKVValue( + userId, + updatedSessionIds, + { storeName: config.defaultStoreName } + ); + + if (!sessionsListResponse.success) { + return NextResponse.json( + { error: sessionsListResponse.error || 'Failed to update sessions list' }, + { status: sessionsListResponse.statusCode || 500 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} + +/** + * POST /api/sessions/[sessionId]/messages - Add a message to a session + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const paramsData = await params; + const sessionId = paramsData.sessionId; + const sessionKey = `${userId}_${sessionId}`; + + const validation = await parseAndValidateJSON(request, SessionMessageOnlyRequestSchema); + + if (!validation.success) { + return validation.response; + } + + const { message } = validation.data; + + // Get current session + const sessionResponse = await getKVValue(sessionKey, { storeName: config.defaultStoreName }); + if (!sessionResponse.success || !sessionResponse.data) { + return NextResponse.json( + { error: 'Session not found' }, + { status: 404 } + ); + } + + const session = sessionResponse.data; + const updatedSession: Session = { + ...session, + messages: [...session.messages, message] + }; + + // Update the individual session + const updateResponse = await setKVValue( + sessionKey, + updatedSession, + { storeName: config.defaultStoreName } + ); + + if (!updateResponse.success) { + return NextResponse.json( + { error: updateResponse.error || 'Failed to update session' }, + { status: updateResponse.statusCode || 500 } + ); + } + + // Move this session ID to the top of the master list (most recently used) + const allSessionsResponse = await getKVValue(userId, { storeName: config.defaultStoreName }); + const sessionIds = allSessionsResponse.success ? allSessionsResponse.data || [] : []; + + // Remove the current session ID if it exists and add it to the beginning + const filteredSessionIds = sessionIds.filter(id => id !== sessionKey); + const updatedSessionIds = [sessionKey, ...filteredSessionIds]; + + const sessionsListResponse = await setKVValue( + userId, + updatedSessionIds, + { storeName: config.defaultStoreName } + ); + + if (!sessionsListResponse.success) { + // Log the error but don't fail the request since we already updated the individual session + console.error('Failed to update sessions list:', sessionsListResponse.error); + } + + return NextResponse.json({ success: true, session: updatedSession }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts new file mode 100644 index 00000000..bd6a97f2 --- /dev/null +++ b/app/api/sessions/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getKVValue, setKVValue } from '@/lib/kv-store'; +import { Session, Message, SessionSchema } from '@/app/chat/types'; +import { toISOString } from '@/app/chat/utils/dateUtils'; +import { config } from '@/lib/config'; +import { parseAndValidateJSON } from '@/lib/validation/middleware'; + +// Constants +const DEFAULT_SESSIONS_LIMIT = 10; +const MAX_SESSIONS_LIMIT = 50; + +/** + * GET /api/sessions - Get all sessions (paginated) + */ +export async function GET(request: NextRequest) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const searchParams = request.nextUrl.searchParams; + const parsedLimit = Number.parseInt(searchParams.get('limit') ?? String(DEFAULT_SESSIONS_LIMIT)); + const parsedCursor = Number.parseInt(searchParams.get('cursor') ?? '0'); + + const limit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), MAX_SESSIONS_LIMIT) : DEFAULT_SESSIONS_LIMIT; + const cursor = Number.isFinite(parsedCursor) ? Math.max(parsedCursor, 0) : 0; + + const response = await getKVValue(userId, { storeName: config.defaultStoreName }); + if (!response.success) { + if (response.statusCode === 404) { + return NextResponse.json({ sessions: [], pagination: { cursor, nextCursor: null, hasMore: false, total: 0, limit } }); + } + return NextResponse.json( + { error: response.error || 'Failed to retrieve sessions' }, + { status: response.statusCode || 500 } + ); + } + + if (!response.data?.length) { + return NextResponse.json({ sessions: [], pagination: { cursor, nextCursor: null, hasMore: false, total: 0, limit } }); + } + + const sessionIds = response.data; + const total = sessionIds.length; + + const start = Math.min(cursor, total); + const end = Math.min(start + limit, total); + const pageIds = sessionIds.slice(start, end); + + const sessionPromises = pageIds.map(sessionId => getKVValue(sessionId, { storeName: config.defaultStoreName })); + const sessionResults = await Promise.all(sessionPromises); + const sessions = sessionResults + .filter(result => result.success && result.data) + .map(result => result.data as Session); + + const hasMore = end < total; + const nextCursor = hasMore ? end : null; + + return NextResponse.json({ + sessions, + pagination: { cursor: start, nextCursor, hasMore, total, limit } + }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} + +/** + * POST /api/sessions - Create a new session + */ +export async function POST(request: NextRequest) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const validation = await parseAndValidateJSON(request, SessionSchema); + if (!validation.success) { + return validation.response; + } + + const session = validation.data; + + // Process any messages to ensure timestamps are in ISO string format + if (session.messages && session.messages.length > 0) { + session.messages = session.messages.map((message: Message) => { + if (message.timestamp) { + return { + ...message, + timestamp: toISOString(message.timestamp) + }; + } + return message; + }); + } + + const sessionKey = `${userId}_${session.sessionId}`; + + // Save the session data + const sessionResponse = await setKVValue( + sessionKey, + session, + { storeName: config.defaultStoreName } + ); + + if (!sessionResponse.success) { + return NextResponse.json( + { error: sessionResponse.error || 'Failed to create session' }, + { status: sessionResponse.statusCode || 500 } + ); + } + + // Update the sessions list with just the session ID + const allSessionsResponse = await getKVValue(userId, { storeName: config.defaultStoreName }); + const sessionIds = allSessionsResponse.success ? allSessionsResponse.data || [] : []; + + // Add the new session ID to the beginning of the array + const updatedSessionIds = [sessionKey, ...sessionIds.filter(id => id !== sessionKey)]; + + const sessionsListResponse = await setKVValue( + userId, + updatedSessionIds, + { storeName: config.defaultStoreName } + ); + + if (!sessionsListResponse.success) { + return NextResponse.json( + { error: sessionsListResponse.error || 'Failed to update sessions list' }, + { status: sessionsListResponse.statusCode || 500 } + ); + } + + return NextResponse.json({ + success: true, + session, + ...(session.title ? {} : { titleGeneration: 'pending' }) + }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} diff --git a/app/api/tutorials/[id]/route.ts b/app/api/tutorials/[id]/route.ts new file mode 100644 index 00000000..637fb1a3 --- /dev/null +++ b/app/api/tutorials/[id]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { readAllTutorials } from '@/lib/tutorial/all-tutorials-reader'; +import { resolve } from 'path'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const basePath = resolve(process.cwd()); + + // Read all tutorials and find the one with matching ID from meta.json + const allTutorials = await readAllTutorials(basePath); + const tutorial = allTutorials.find(t => t.id === id); + + if (!tutorial) { + return NextResponse.json( + { + success: false, + error: 'Tutorial not found' + }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + data: tutorial + }); + } catch (error) { + console.error('Error reading tutorial:', error); + return NextResponse.json( + { + success: false, + error: 'Failed to read tutorial', + message: error instanceof Error ? error.message : String(error) + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/tutorials/[id]/steps/[stepNumber]/route.ts b/app/api/tutorials/[id]/steps/[stepNumber]/route.ts new file mode 100644 index 00000000..e949733d --- /dev/null +++ b/app/api/tutorials/[id]/steps/[stepNumber]/route.ts @@ -0,0 +1,178 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { join, resolve, sep } from 'path'; +import { readFile } from 'fs/promises'; +import matter from 'gray-matter'; +import { validateTutorialId, validateStepNumber, createValidationError } from '@/lib/validation/middleware'; + +interface RouteParams { + params: Promise<{ id: string; stepNumber: string }>; +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { id, stepNumber } = await params; + + const idValidation = validateTutorialId(id); + if (!idValidation.success) { + return createValidationError('Invalid tutorial ID', idValidation.errors || []); + } + + const stepValidation = validateStepNumber(stepNumber); + if (!stepValidation.success) { + return createValidationError('Invalid step number', stepValidation.errors || []); + } + + const stepIndex = stepValidation.data; + if (!stepIndex) { + return NextResponse.json( + { success: false, error: 'Invalid step number' }, + { status: 400 } + ); + } + + const repoRoot = process.cwd(); + const tutorialDir = join(repoRoot, 'content', 'Tutorial', id); + + // Load child tutorial meta.json to get ordered pages + const childMetaRaw = await readFile(join(tutorialDir, 'meta.json'), 'utf-8'); + const childMeta = JSON.parse(childMetaRaw) as { title?: string; pages?: string[] }; + const pages = (childMeta.pages ?? []).filter(Boolean); + + // Filter out index; map to actual MDX files + const stepSlugs = pages.filter(p => p !== 'index'); + + if (stepIndex < 1 || stepIndex > stepSlugs.length) { + return NextResponse.json( + { success: false, error: 'Step not found' }, + { status: 404 } + ); + } + + const slug = stepSlugs[stepIndex - 1]; + if (!slug) { + return NextResponse.json( + { success: false, error: 'Step not found' }, + { status: 404 } + ); + } + + const mdxPath = join(tutorialDir, `${slug}.mdx`); + const mdxRaw = await readFile(mdxPath, 'utf-8'); + const parsed = matter(mdxRaw); + + // Extract CodeFromFiles tags and resolve their snippet arrays + const snippets: Array<{ path: string; lang?: string; from?: number; to?: number; title?: string; content: string }> = []; + + // Helper to load a snippet descriptor into content + async function loadSnippet(desc: { path: string; lang?: string; from?: number; to?: number; title?: string }) { + const filePath = desc.path; + if (!filePath || !filePath.startsWith('/examples/')) return; + + // Resolve against repo root and ensure containment within /examples + const resolvedPath = resolve(repoRoot, `.${filePath}`); + const examplesBase = resolve(repoRoot, 'examples'); + const isContained = resolvedPath === examplesBase || resolvedPath.startsWith(examplesBase + sep); + if (!isContained) return; + + try { + const fileRaw = await readFile(resolvedPath, 'utf-8'); + const lines = fileRaw.split(/\r?\n/); + const startIdx = Math.max(0, (desc.from ? desc.from - 1 : 0)); + const endIdx = Math.min(lines.length, desc.to ? desc.to : lines.length); + const content = lines.slice(startIdx, endIdx).join('\n'); + snippets.push({ ...desc, content }); + } catch (error) { + console.warn(`Failed to load snippet from ${filePath}:`, error); + } + } + + // 1) Parse blocks + // Robust parser that balances braces to extract snippets={[ ... ]} + const filesTagRegex = /]*?)\/>/g; + let filesTagMatch: RegExpExecArray | null; + while ((filesTagMatch = filesTagRegex.exec(parsed.content)) !== null) { + const propsSrc: string = filesTagMatch[1] || ''; + + const key = 'snippets={'; + const start = propsSrc.indexOf(key); + if (start < 0) continue; + let i = start + key.length; // position after '{' + let depth = 1; + // Scan until matching closing '}' for the snippets prop + while (i < propsSrc.length && depth > 0) { + const ch = propsSrc[i]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + // slice without outer braces + const inner = propsSrc.slice(start + key.length, i - 1).trim(); // should be array source like "[{...},{...}]" + + // Extract object literals from the array by balancing braces again + const objects: string[] = []; + let j = 0; + while (j < inner.length) { + if (inner[j] === '{') { + let d = 1; + let k = j + 1; + while (k < inner.length && d > 0) { + const ch = inner[k]; + if (ch === '{') d++; + else if (ch === '}') d--; + k++; + } + objects.push(inner.slice(j, k)); + j = k; + } else { + j++; + } + } + + for (const objSrc of objects) { + const getStr = (name: string): string | undefined => { + const r = new RegExp(name + '\\s*:\\s*"([^"]*)"'); + const mm = r.exec(objSrc); + return mm ? mm[1] : undefined; + }; + const getNum = (name: string): number | undefined => { + const r = new RegExp(name + '\\s*:\\s*(\\d+)'); + const mm = r.exec(objSrc); + return mm ? Number(mm[1]) : undefined; + }; + const desc = { + path: getStr('path') || '', + lang: getStr('lang'), + from: getNum('from'), + to: getNum('to'), + title: getStr('title') + }; + if (desc.path) { + await loadSnippet(desc); + } + } + } + + return NextResponse.json({ + success: true, + data: { + tutorialId: id, + stepNumber: stepIndex, + slug, + meta: parsed.data ?? {}, + mdx: parsed.content, + snippets, + totalSteps: pages.length + } + }); + } catch (error) { + console.error('Error reading tutorial step:', error); + return NextResponse.json( + { + success: false, + error: 'Failed to read tutorial step', + message: error instanceof Error ? error.message : String(error) + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/tutorials/route.ts b/app/api/tutorials/route.ts new file mode 100644 index 00000000..be81bb7c --- /dev/null +++ b/app/api/tutorials/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { join } from 'path'; +import { readFile, readdir, stat } from 'fs/promises'; +import matter from 'gray-matter'; + +export async function GET(request: NextRequest) { + try { + const repoRoot = process.cwd(); + const tutorialRoot = join(repoRoot, 'content', 'Tutorial'); + + // Use parent meta.json to control order and which tutorials show up + const parentMetaPath = join(tutorialRoot, 'meta.json'); + const parentMetaRaw = await readFile(parentMetaPath, 'utf-8'); + const parentMeta = JSON.parse(parentMetaRaw) as { title?: string; pages?: string[] }; + const pages = (parentMeta.pages ?? []).filter(Boolean); + + const results: Array<{ id: string; title: string; description?: string; totalSteps: number }> = []; + + for (const entry of pages) { + if (entry.includes('..') || entry.includes('/') || entry.includes('\\')) { + continue; + } + + const dirPath = join(tutorialRoot, entry); + const filePath = join(tutorialRoot, `${entry}.mdx`); + try { + const st = await stat(dirPath).catch(() => null); + if (st && st.isDirectory()) { + // Directory tutorial: read its meta.json and index.mdx (if present) + const childMetaRaw = await readFile(join(dirPath, 'meta.json'), 'utf-8'); + const childMeta = JSON.parse(childMetaRaw) as { title?: string; pages?: string[] }; + + const pagesList = childMeta.pages ?? []; + const totalSteps = pagesList.filter(p => p !== 'index').length || 0; + + let description: string | undefined; + try { + const idxPath = join(dirPath, 'index.mdx'); + const idxRaw = await readFile(idxPath, 'utf-8'); + const idx = matter(idxRaw); + if (typeof idx.data?.description === 'string') description = idx.data.description; + } catch { + // ignore if index missing + } + + results.push({ + id: entry, + title: childMeta.title || entry, + description, + totalSteps + }); + } else { + // Single-file tutorial + const mdxRaw = await readFile(filePath, 'utf-8'); + const fm = matter(mdxRaw); + const title = (fm.data?.title as string) || entry; + const description = typeof fm.data?.description === 'string' ? (fm.data.description as string) : undefined; + results.push({ id: entry, title, description, totalSteps: 1 }); + } + } catch (err) { + // Skip malformed entries but continue + // eslint-disable-next-line no-console + console.warn(`Skipping tutorial entry ${entry}:`, err); + } + } + + return NextResponse.json(results); + } catch (error) { + console.error('Error reading tutorials:', error); + + // Return proper HTTP error status with minimal error info + return NextResponse.json( + { error: 'Failed to read tutorials' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/users/tutorial-state/route.ts b/app/api/users/tutorial-state/route.ts new file mode 100644 index 00000000..70557fa7 --- /dev/null +++ b/app/api/users/tutorial-state/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { TutorialStateManager } from '@/lib/tutorial/state-manager'; +import { setKVValue } from '@/lib/kv-store'; +import { config } from '@/lib/config'; +import { + parseAndValidateJSON, + TutorialProgressRequestSchema, + TutorialResetRequestSchema +} from '@/lib/validation/middleware'; + +/** + * GET /api/users/tutorial-state - Get current user's tutorial state + */ +export async function GET(request: NextRequest) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const tutorialState = await TutorialStateManager.getUserTutorialState(userId); + + return NextResponse.json({ + success: true, + data: tutorialState + }); + } catch (error) { + console.error('Error getting tutorial state:', error); + return NextResponse.json( + { error: 'Failed to get tutorial state' }, + { status: 500 } + ); + } +} + +/** + * POST /api/users/tutorial-state - Update tutorial progress + */ +export async function POST(request: NextRequest) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const validation = await parseAndValidateJSON(request, TutorialProgressRequestSchema); + if (!validation.success) { + return validation.response; + } + + const { tutorialId, currentStep, totalSteps } = validation.data; + + await TutorialStateManager.updateTutorialProgress( + userId, + tutorialId, + currentStep, + totalSteps + ); + + return NextResponse.json({ + success: true, + message: 'Tutorial progress updated' + }); + } catch (error) { + console.error('Error updating tutorial state:', error); + return NextResponse.json( + { error: 'Failed to update tutorial state' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/users/tutorial-state - Reset tutorial progress + */ +export async function DELETE(request: NextRequest) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const validation = await parseAndValidateJSON(request, TutorialResetRequestSchema); + if (!validation.success) { + return validation.response; + } + + const { tutorialId } = validation.data; + + const state = await TutorialStateManager.getUserTutorialState(userId); + if (!state.tutorials) { + state.tutorials = {}; + } + delete state.tutorials[tutorialId]; + + // Save the updated state + const kvResponse = await setKVValue(`tutorial_state_${userId}`, state, { + storeName: config.defaultStoreName + }); + + if (!kvResponse.success) { + return NextResponse.json( + { error: kvResponse.error || 'Failed to reset tutorial state' }, + { status: kvResponse.statusCode || 500 } + ); + } + + return NextResponse.json({ + success: true, + message: 'Tutorial progress reset' + }); + } catch (error) { + console.error('Error resetting tutorial state:', error); + return NextResponse.json( + { error: 'Failed to reset tutorial state' }, + { status: 500 } + ); + } +} diff --git a/app/chat/SessionContext.tsx b/app/chat/SessionContext.tsx new file mode 100644 index 00000000..03e36b79 --- /dev/null +++ b/app/chat/SessionContext.tsx @@ -0,0 +1,20 @@ + import { createContext, useContext } from 'react'; + import { Session } from './types'; + + interface SessionContextType { + sessions: Session[]; + setSessions: (updater: React.SetStateAction, options?: { revalidate: boolean }) => void; + currentSessionId: string; + // A simple trigger to revalidate sessions; implementation may vary under the hood + revalidateSessions?: () => void | Promise; + } + +export const SessionContext = createContext(undefined); + +export const useSessions = () => { + const context = useContext(SessionContext); + if (!context) { + throw new Error('useSessions must be used within a SessionProvider'); + } + return context; +}; diff --git a/app/chat/[sessionId]/page.tsx b/app/chat/[sessionId]/page.tsx new file mode 100644 index 00000000..ef569ef4 --- /dev/null +++ b/app/chat/[sessionId]/page.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from "next/navigation"; +import { Allotment } from "allotment"; +import "allotment/dist/style.css"; +import { v4 as uuidv4 } from 'uuid'; +import { ChatMessagesArea } from '../components/ChatMessagesArea'; +import { CodeEditor } from '../components/CodeEditor'; +import { Session, Message } from '../types'; +import { useSessions } from '../SessionContext'; +import { sessionService } from '../services/sessionService'; +import { Skeleton } from '@/components/ui/skeleton'; + +export default function ChatSessionPage() { + const { sessionId } = useParams<{ sessionId: string }>(); + const [session, setSession] = useState(); + const [editorOpen, setEditorOpen] = useState(false); + const [editorContent, setEditorContent] = useState(''); + const { sessions, setSessions, revalidateSessions } = useSessions(); + const [creationError, setCreationError] = useState(null); + + + const handleSendMessage = async (content: string, sessionId: string) => { + if (!content || !sessionId) return; + + const newMessage: Message = { + id: uuidv4(), + author: 'USER', + content: content, + timestamp: new Date().toISOString() + }; + + const assistantMessage: Message = { + id: uuidv4(), + author: 'ASSISTANT', + content: '', + timestamp: new Date().toISOString() + }; + + try { + setSession(prevSession => { + if (!prevSession) return prevSession; + return { + ...prevSession, + messages: [...prevSession.messages, newMessage, assistantMessage] + }; + }); + + await sessionService.addMessageToSessionStreaming( + sessionId, + newMessage, + { + onTextDelta: (textDelta) => { + setSession(prev => { + if (!prev) return prev; + const updatedMessages = prev.messages.map(msg => { + if (msg.id === assistantMessage.id) { + return { + ...msg, + content: msg.content + textDelta + }; + } + return msg; + }); + return { ...prev, messages: updatedMessages }; + }); + }, + + onTutorialData: (tutorialData) => { + setSession(prev => { + if (!prev) return prev; + const updatedMessages = prev.messages.map(msg => + msg.id === assistantMessage.id + ? { ...msg, tutorialData: tutorialData } + : msg + ); + return { ...prev, messages: updatedMessages }; + }); + }, + + onFinish: (finalSession) => { + setSession(finalSession); + setSessions(prev => prev.map(s => s.sessionId === sessionId ? finalSession : s)); + }, + + onError: (error) => { + console.error('Error sending message:', error); + setSession(prev => { + if (!prev) return prev; + const updatedMessages = prev.messages.map(msg => + msg.id === assistantMessage.id + ? { ...msg, content: 'Sorry, I encountered an error. Please try again.' } + : msg + ); + return { ...prev, messages: updatedMessages }; + }); + } + } + ); + + } catch (error) { + console.error('Error sending message:', error); + setSession(prevSession => { + if (!prevSession) return prevSession; + const filteredMessages = prevSession.messages.filter(msg => + msg.id !== newMessage.id && msg.id !== assistantMessage.id + ); + return { ...prevSession, messages: filteredMessages }; + }); + } + }; + + useEffect(() => { + const foundSession = sessions.find(s => s.sessionId === sessionId); + if (foundSession) { + setSession(foundSession); + return; + } + + const storageKey = `initialMessage:${sessionId}`; + const initialMessage = sessionStorage.getItem(storageKey); + if (!initialMessage) { + return; + } + sessionStorage.removeItem(storageKey); + + const userMessage: Message = { + id: uuidv4(), + author: 'USER', + content: initialMessage, + timestamp: new Date().toISOString(), + }; + const assistantPlaceholder: Message = { + id: uuidv4(), + author: 'ASSISTANT', + content: '', + timestamp: new Date().toISOString(), + }; + const temporarySession: Session = { + sessionId: sessionId as string, + messages: [userMessage, assistantPlaceholder], + }; + + setSession(temporarySession); + + sessionService.createSession({ + sessionId: sessionId as string, + messages: [] + }) + .then(async response => { + if (response.success && response.data) { + setSession(response.data); + await handleSendMessage(initialMessage, sessionId); + } else { + setCreationError(response.error || 'Failed to create session'); + revalidateSessions?.(); + } + }) + .catch(error => { + setCreationError(error.message || 'Error creating session'); + revalidateSessions?.(); + }); + }, [sessionId]); + + + const toggleEditor = () => { setEditorOpen(false) }; + const stopServer = () => { }; + + return ( +
+ {/* Non-blocking error banner */} + {creationError && ( +
+
+ Error creating session: {creationError} + +
+
+ )} + + + +
+
+ {session ? ( + { setEditorOpen(true) }} + /> + ) : ( +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ )} +
+
+
+ {editorOpen && ( + +
+ { }} + stopServer={stopServer} + editorContent={editorContent} + setEditorContent={setEditorContent} + toggleEditor={toggleEditor} + /> +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/chat/components/ChatInput.tsx b/app/chat/components/ChatInput.tsx new file mode 100644 index 00000000..0422b2ac --- /dev/null +++ b/app/chat/components/ChatInput.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useEffect, KeyboardEvent, useState } from 'react'; +import { Send } from 'lucide-react'; +import { useAutoResize } from '../utils/useAutoResize'; + +interface ChatInputProps { + loading?: boolean; + onSendMessage: (message: string) => void; +} + +export function ChatInput({ + loading = false, + onSendMessage +}: ChatInputProps) { + const [currentInput, setCurrentInput] = useState(''); + const { textareaRef } = useAutoResize(currentInput, { maxHeight: 320 }); + + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + useEffect(() => { + if (!loading) { + textareaRef.current?.focus(); + } + }, [loading]); + + const handleSend = () => { + if (currentInput.trim() && !loading) { + onSendMessage(currentInput.trim()); + setCurrentInput(''); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && (!e.shiftKey || e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+ {/* Textarea Container */} +
+
+