Repository navigation
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Adds an end-to-end “Portfolio Chat” feature: a new React chat page/UI that calls a new Express + TypeScript backend implementing simple local retrieval (RAG-style) and a Cerebras chat-completions integration.
Changes:
- Add
/chatroute + navigation link, and hash-based section navigation/scroll behavior on the home page. - Add chat UI (
PortfolioChat) and a small frontend API client to call/api/chat. - Add a new backend service (Express server, ingestion, retrieval, Cerebras client) plus dev proxy/docs updates.
Reviewed changes
Copilot reviewed 24 out of 27 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| vite.config.ts | Adds Vite dev proxy for /api to the local chat backend. |
| src/pages/index.tsx | Adds hash-aware smooth scrolling so /#section navigation works after routing. |
| src/pages/Chat.tsx | Introduces dedicated chat page layout (background + nav + chat UI). |
| src/lib/chat-api.ts | Adds sendChat helper and shared frontend types for chat requests/responses. |
| src/components/PortfolioChat.tsx | Implements chat UI, message rendering (Markdown), and submit/loading/error handling. |
| src/components/Navigation.tsx | Updates section navigation to work across routes and adds link to /chat. |
| src/App.tsx | Registers the /chat route. |
| package.json | Adds react-markdown + remark-gfm dependencies. |
| bun.lock | Locks new frontend dependencies. |
| backend/tsconfig.json | Adds backend TypeScript configuration. |
| backend/src/types/chat.ts | Defines backend chat/retrieval types. |
| backend/src/services/retrieval.ts | Implements local token-based retrieval + chunking. |
| backend/src/services/mastraAdapter.ts | Adds a temporary context formatting seam for future RAG pipeline changes. |
| backend/src/services/chatService.ts | Orchestrates bootstrap (ingest/index) + retrieval + Cerebras call. |
| backend/src/services/cerebras.ts | Implements Cerebras chat completion client. |
| backend/src/server.ts | Sets up Express server, CORS, health endpoint, and mounts /api/chat. |
| backend/src/routes/chat.ts | Adds validated /api/chat POST endpoint (zod). |
| backend/src/ingest/loadDocuments.ts | Loads knowledge files from repo and backend/knowledge/* for retrieval context. |
| backend/package.json | Adds backend package definition and scripts. |
| backend/knowledge/cv/README.md | Documents where to place CV knowledge files. |
| backend/knowledge/about/README.md | Documents where to place “about” knowledge files. |
| backend/bun.lock | Locks backend dependencies. |
| backend/README.md | Backend setup/run instructions. |
| backend/.env.example | Backend environment variable template. |
| README.md | Updates root documentation to include chat feature and backend setup. |
| .gitignore | Ignores backend/.env. |
| .env.example | Adds frontend env template for optional backend base URL. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const onSubmit = async (event: FormEvent) => { | ||
| event.preventDefault(); | ||
|
|
||
| const nextMessage = input.trim(); | ||
| if (!nextMessage || isLoading) return; | ||
|
|
||
| setIsLoading(true); | ||
| setError(null); | ||
| setInput(''); | ||
|
|
||
| const userMessage: Message = { role: 'user', content: nextMessage }; | ||
| setMessages((prev) => [...prev, userMessage]); | ||
|
|
||
| try { | ||
| const response = await sendChat({ | ||
| message: nextMessage, | ||
| history: [...history, { role: 'user', content: nextMessage }], | ||
| }); |
There was a problem hiding this comment.
New chat interaction logic (submit -> loading state -> success/error rendering) isn’t covered by tests. The repo already has component tests under src/components/tests, so this should have at least a basic test verifying submit behavior (disables button while loading, appends user message, renders assistant response, and shows an error on failure).
|
|
||
| dotenv.config(); | ||
|
|
||
| const port = Number(process.env.PORT ?? 8787); |
There was a problem hiding this comment.
PORT is parsed with Number(...), so an invalid value (e.g. PORT=abc) becomes NaN and will cause app.listen(...) to throw at runtime. Consider validating the parsed value (Number.isFinite) and either defaulting to 8787 or throwing a clear configuration error.
| const port = Number(process.env.PORT ?? 8787); | |
| const envPort = process.env.PORT; | |
| const port = envPort === undefined ? 8787 : Number(envPort); | |
| if (!Number.isFinite(port)) { | |
| throw new Error(`Invalid PORT environment variable: "${envPort}". Expected a numeric port.`); | |
| } |
| 2. Start the chat backend in another terminal: | ||
| ```bash | ||
| cd backend | ||
| cp .env.example .env | ||
| # set CEREBRAS_API_KEY in backend/.env | ||
| bun run dev | ||
| ``` |
There was a problem hiding this comment.
The root README uses bun run dev to start the backend, while the backend’s own README/scripts are written for pnpm dev (and the repo’s packageManager is pnpm). This inconsistency can confuse setup.
Align the command in the root README with the backend package.json scripts (e.g., pnpm dev from within backend/).
| async ask(message: string, history: ChatTurn[]) { | ||
| if (!this.isReady) { | ||
| await this.bootstrap(); | ||
| } |
There was a problem hiding this comment.
ask() can call bootstrap() concurrently on multiple requests if they arrive before isReady flips to true, causing duplicate filesystem reads / indexing work (and potential racey logs). Consider guarding bootstrap with a shared promise (e.g., private bootstrapPromise?: Promise<void>) so only one bootstrap runs and others await it.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 27 changed files in this pull request and generated 18 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async ask(message: string, history: ChatTurn[]) { | ||
| if (!this.isReady) { | ||
| await this.bootstrap(); | ||
| } |
There was a problem hiding this comment.
The chat service bootstrap is called on the first request (lazy initialization). If document loading is slow or fails, the first user will experience a long delay or see an error. Consider one of the following approaches:
- Bootstrap eagerly when the server starts (call
chatService.bootstrap()right after instantiation) - Add a startup health check that verifies bootstrap completed successfully
- Add a timeout to bootstrap to prevent indefinite hangs
This would provide better user experience and make deployment issues more visible.
| <div className="max-h-[48vh] space-y-3 overflow-y-auto p-4"> | ||
| {messages.map((message, index) => ( | ||
| <ChatMessage key={`${message.role}-${index}`} message={message} /> | ||
| ))} | ||
| {isLoading ? <p className="text-muted-foreground text-sm">回答を生成中...</p> : null} | ||
| {error ? <p className="text-destructive text-xs">{error}</p> : null} | ||
| </div> |
There was a problem hiding this comment.
The chat messages container lacks proper ARIA attributes for screen readers. Consider adding:
role="log"orrole="region"with an appropriatearia-labelaria-live="polite"to announce new messagesaria-atomic="false"to announce only new messages, not the entire list
This would improve accessibility for users who rely on screen readers to understand when new messages arrive.
| {isLoading ? <p className="text-muted-foreground text-sm">回答を生成中...</p> : null} | ||
| {error ? <p className="text-destructive text-xs">{error}</p> : null} |
There was a problem hiding this comment.
The loading and error states are displayed as paragraph elements without proper ARIA live region attributes. Screen reader users may not be aware that the status has changed. Consider adding:
role="status"andaria-live="polite"for the loading messagerole="alert"andaria-live="assertive"for error messages
This would ensure that status changes are properly announced to assistive technology users.
| {isLoading ? <p className="text-muted-foreground text-sm">回答を生成中...</p> : null} | |
| {error ? <p className="text-destructive text-xs">{error}</p> : null} | |
| {isLoading ? ( | |
| <p className="text-muted-foreground text-sm" role="status" aria-live="polite"> | |
| 回答を生成中... | |
| </p> | |
| ) : null} | |
| {error ? ( | |
| <p className="text-destructive text-xs" role="alert" aria-live="assertive"> | |
| {error} | |
| </p> | |
| ) : null} |
| private fallback(limit: number): Chunk[] { | ||
| const preferredTypes: Array<Chunk['sourceType']> = ['about', 'cv', 'site']; | ||
| const ordered: Chunk[] = []; | ||
|
|
||
| for (const sourceType of preferredTypes) { | ||
| for (const chunk of this.chunks) { | ||
| if (chunk.sourceType === sourceType) { | ||
| ordered.push(chunk); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return ordered.slice(0, limit); | ||
| } |
There was a problem hiding this comment.
The fallback method iterates through all chunks for each source type, resulting in O(n*m) complexity where n is the number of chunks and m is the number of source types. For large document sets, this could be slow.
Consider optimizing by:
- Pre-grouping chunks by sourceType in the upsertDocuments method
- Storing chunks in a Map keyed by sourceType
- Using a single pass to collect chunks from preferred types
This would improve performance when the fallback is triggered (e.g., when queries have no matching tokens).
| role: z.enum(['user', 'assistant']), | ||
| content: z.string().min(1), | ||
| }), | ||
| ) |
There was a problem hiding this comment.
The chat history array has no maximum length validation. This could allow unbounded history arrays to be sent to the API, potentially causing:
- Large payload sizes that could trigger the 1mb body limit
- Excessive token usage in the Cerebras API call
- Potential performance issues
Consider adding a maximum length constraint to the history array validation, for example:
history: z.array(...).max(50).default([])
This would prevent abuse and ensure predictable API usage.
| ) | |
| ) | |
| .max(50) |
| const [error, setError] = useState<string | null>(null); | ||
|
|
||
| const history = useMemo<ChatTurn[]>( | ||
| () => messages.filter((m) => m !== INITIAL_MESSAGE).map((m) => ({ role: m.role, content: m.content })), |
There was a problem hiding this comment.
The INITIAL_MESSAGE is filtered out from the history using object reference equality. This approach is fragile because:
- If INITIAL_MESSAGE is accidentally mutated, the comparison will fail
- If the message is serialized/deserialized (e.g., from localStorage), the reference will be lost
Consider using a more robust approach such as:
- Adding an
idfield to messages and filtering byid !== 'initial' - Filtering based on a flag like
isInitial: true - Simply skipping the first message if it matches the expected content
This would make the code more maintainable and less prone to bugs.
| () => messages.filter((m) => m !== INITIAL_MESSAGE).map((m) => ({ role: m.role, content: m.content })), | |
| () => { | |
| const nonInitialMessages = | |
| messages.length > 0 && | |
| messages[0].role === INITIAL_MESSAGE.role && | |
| messages[0].content === INITIAL_MESSAGE.content | |
| ? messages.slice(1) | |
| : messages; | |
| return nonInitialMessages.map((m) => ({ role: m.role, content: m.content })); | |
| }, |
| import { sendChat, type ChatTurn } from '@/lib/chat-api'; | ||
| import { FormEvent, memo, useMemo, useState } from 'react'; | ||
| import ReactMarkdown from 'react-markdown'; | ||
| import remarkGfm from 'remark-gfm'; | ||
|
|
||
| type Message = { | ||
| role: ChatTurn['role']; | ||
| content: string; | ||
| sources?: Array<{ title: string; sourceType: string }>; | ||
| }; | ||
|
|
||
| const INITIAL_MESSAGE: Message = { | ||
| role: 'assistant', | ||
| content: 'ポートフォリオについて質問できます。例えば「得意技術は?」「最近のプロジェクトは?」など。', | ||
| }; | ||
|
|
||
| const REMARK_PLUGINS = [remarkGfm]; | ||
|
|
||
| const ChatMessage = memo(({ message }: { message: Message }) => ( | ||
| <div | ||
| className={`rounded-lg px-3 py-2 text-sm ${ | ||
| message.role === 'assistant' ? 'bg-muted text-foreground' : 'bg-primary text-primary-foreground' | ||
| }`} | ||
| > | ||
| <ReactMarkdown | ||
| className="prose prose-sm max-w-none whitespace-pre-wrap break-words prose-p:my-2 prose-table:my-2 prose-th:border prose-th:px-2 prose-th:py-1 prose-td:border prose-td:px-2 prose-td:py-1 prose-ul:my-2 prose-ol:my-2 prose-li:my-1" | ||
| remarkPlugins={REMARK_PLUGINS} | ||
| > | ||
| {message.content} | ||
| </ReactMarkdown> | ||
| {message.sources && message.sources.length > 0 ? ( | ||
| <p className="mt-2 text-[11px] opacity-80"> | ||
| Sources:{' '} | ||
| {message.sources | ||
| .slice(0, 3) | ||
| .map((source) => `${source.title} (${source.sourceType})`) | ||
| .join(', ')} | ||
| </p> | ||
| ) : null} | ||
| </div> | ||
| )); | ||
|
|
||
| ChatMessage.displayName = 'ChatMessage'; | ||
|
|
||
| const ChatMessages = memo( | ||
| ({ | ||
| messages, | ||
| isLoading, | ||
| error, | ||
| }: { | ||
| messages: Message[]; | ||
| isLoading: boolean; | ||
| error: string | null; | ||
| }) => ( | ||
| <div className="max-h-[48vh] space-y-3 overflow-y-auto p-4"> | ||
| {messages.map((message, index) => ( | ||
| <ChatMessage key={`${message.role}-${index}`} message={message} /> | ||
| ))} | ||
| {isLoading ? <p className="text-muted-foreground text-sm">回答を生成中...</p> : null} | ||
| {error ? <p className="text-destructive text-xs">{error}</p> : null} | ||
| </div> | ||
| ), | ||
| ); | ||
|
|
||
| ChatMessages.displayName = 'ChatMessages'; | ||
|
|
||
| const PortfolioChat = () => { | ||
| const [input, setInput] = useState(''); | ||
| const [isLoading, setIsLoading] = useState(false); | ||
| const [messages, setMessages] = useState<Message[]>([INITIAL_MESSAGE]); | ||
| const [error, setError] = useState<string | null>(null); | ||
|
|
||
| const history = useMemo<ChatTurn[]>( | ||
| () => messages.filter((m) => m !== INITIAL_MESSAGE).map((m) => ({ role: m.role, content: m.content })), | ||
| [messages], | ||
| ); | ||
|
|
||
| const onSubmit = async (event: FormEvent) => { | ||
| event.preventDefault(); | ||
|
|
||
| const nextMessage = input.trim(); | ||
| if (!nextMessage || isLoading) return; | ||
|
|
||
| setIsLoading(true); | ||
| setError(null); | ||
| setInput(''); | ||
|
|
||
| const userMessage: Message = { role: 'user', content: nextMessage }; | ||
| setMessages((prev) => [...prev, userMessage]); | ||
|
|
||
| try { | ||
| const response = await sendChat({ | ||
| message: nextMessage, | ||
| history: [...history, { role: 'user', content: nextMessage }], | ||
| }); | ||
|
|
||
| setMessages((prev) => [ | ||
| ...prev, | ||
| { | ||
| role: 'assistant', | ||
| content: response.answer, | ||
| sources: response.sources.map((source) => ({ | ||
| title: source.title, | ||
| sourceType: source.sourceType, | ||
| })), | ||
| }, | ||
| ]); | ||
| } catch (submitError) { | ||
| const message = submitError instanceof Error ? submitError.message : '不明なエラーが発生しました'; | ||
| setError(message); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <section className="py-16 md:py-24" id="chat"> | ||
| <div className="mx-auto w-[min(100%-2rem,960px)]"> | ||
| <div className="mb-5"> | ||
| <h2 className="font-bold text-2xl md:text-3xl">Portfolio Chat</h2> | ||
| <p className="mt-2 text-muted-foreground text-sm md:text-base"> | ||
| サイト情報・CV・自己紹介データをもとに、あなたの質問に回答します。 | ||
| </p> | ||
| </div> | ||
|
|
||
| <div className="rounded-xl border border-border bg-background/90 shadow-lg backdrop-blur-sm"> | ||
| <ChatMessages messages={messages} isLoading={isLoading} error={error} /> | ||
|
|
||
| <form className="border-t border-border p-4" onSubmit={onSubmit}> | ||
| <textarea | ||
| className="mb-3 min-h-[96px] w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring" | ||
| onChange={(event) => setInput(event.target.value)} | ||
| placeholder="質問を入力..." | ||
| value={input} | ||
| /> | ||
| <button | ||
| className="w-full rounded-md bg-primary px-3 py-2 font-medium text-primary-foreground text-sm disabled:opacity-50" | ||
| disabled={isLoading || input.trim().length === 0} | ||
| type="submit" | ||
| > | ||
| {isLoading ? '送信中...' : '送信'} | ||
| </button> | ||
| </form> | ||
| </div> | ||
| </div> | ||
| </section> | ||
| ); | ||
| }; | ||
|
|
||
| export default PortfolioChat; |
There was a problem hiding this comment.
The new chat functionality (PortfolioChat component, chat-api client, and ChatPage) lacks test coverage. The repository has comprehensive test coverage for other components (as seen in src/components/tests), but none for the chat feature.
Consider adding tests for:
- PortfolioChat component rendering and user interactions
- Form submission and validation
- Error handling scenarios
- Loading states
- Message display and history management
- API client error handling
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
add chat function