Skip to content

add chat function - #54

Open
koji wants to merge 17 commits into
mainfrom
feat_chat-function
Open

koji wants to merge 17 commits into
mainfrom
feat_chat-function

Conversation

@koji

@koji koji commented Feb 21, 2026

Copy link
Copy Markdown
Owner

add chat function

@koji
koji requested a review from Copilot February 21, 2026 22:13
@vercel

vercel Bot commented Feb 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portfolio Ready Ready Preview, Comment Feb 22, 2026 8:36am
portfolio-nextjs Ready Ready Preview, Comment Feb 22, 2026 8:36am

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /chat route + 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.

Comment thread src/components/PortfolioChat.tsx Resolved
Comment on lines +78 to +95
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 }],
});

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread backend/src/server.ts

dotenv.config();

const port = Number(process.env.PORT ?? 8787);

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.`);
}

Copilot uses AI. Check for mistakes.
Comment thread README.md Resolved
Comment thread README.md
Comment on lines +76 to +82
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
```

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/).

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +28
async ask(message: string, history: ChatTurn[]) {
if (!this.isReady) {
await this.bootstrap();
}

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +44 to +47
async ask(message: string, history: ChatTurn[]) {
if (!this.isReady) {
await this.bootstrap();
}

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Bootstrap eagerly when the server starts (call chatService.bootstrap() right after instantiation)
  2. Add a startup health check that verifies bootstrap completed successfully
  3. Add a timeout to bootstrap to prevent indefinite hangs

This would provide better user experience and make deployment issues more visible.

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +61
<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>

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chat messages container lacks proper ARIA attributes for screen readers. Consider adding:

  • role="log" or role="region" with an appropriate aria-label
  • aria-live="polite" to announce new messages
  • aria-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.

Copilot uses AI. Check for mistakes.
Comment thread src/components/PortfolioChat.tsx Outdated
Comment on lines +59 to +60
{isLoading ? <p className="text-muted-foreground text-sm">回答を生成中...</p> : null}
{error ? <p className="text-destructive text-xs">{error}</p> : null}

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" and aria-live="polite" for the loading message
  • role="alert" and aria-live="assertive" for error messages

This would ensure that status changes are properly announced to assistive technology users.

Suggested change
{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}

Copilot uses AI. Check for mistakes.
Comment thread backend/src/services/cerebras.ts Resolved
Comment thread backend/src/services/chatService.ts Resolved
Comment on lines +67 to +80
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);
}

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Pre-grouping chunks by sourceType in the upsertDocuments method
  2. Storing chunks in a Map keyed by sourceType
  3. 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).

Copilot uses AI. Check for mistakes.
role: z.enum(['user', 'assistant']),
content: z.string().min(1),
}),
)

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chat history array has no maximum length validation. This could allow unbounded history arrays to be sent to the API, potentially causing:

  1. Large payload sizes that could trigger the 1mb body limit
  2. Excessive token usage in the Cerebras API call
  3. 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.

Suggested change
)
)
.max(50)

Copilot uses AI. Check for mistakes.
Comment thread backend/src/ingest/loadDocuments.ts Resolved
Comment thread src/components/PortfolioChat.tsx Outdated
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 })),

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The INITIAL_MESSAGE is filtered out from the history using object reference equality. This approach is fragile because:

  1. If INITIAL_MESSAGE is accidentally mutated, the comparison will fail
  2. 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 id field to messages and filtering by id !== '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.

Suggested change
() => 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 }));
},

Copilot uses AI. Check for mistakes.
Comment thread src/components/PortfolioChat.tsx Outdated
Comment on lines +1 to +150
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;

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
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>

This branch was successfully deployed

1 active deployment
Preview — 9e0fca0d Deployed Feb 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants