From e4f3ccfaaf9f26fa187cd58d02f910514c084a63 Mon Sep 17 00:00:00 2001 From: MrFrostDev <25389627+MrFrostDev@users.noreply.github.com> Date: Sat, 23 May 2026 13:58:27 +0200 Subject: [PATCH] feat: add chat assistant with role-aware prompt and vision support --- src/app/api/chat/route.ts | 60 +++ src/components/AIInput.tsx | 269 +++++++++++ src/components/ChatBot.tsx | 54 +++ src/components/ChatSidebar.tsx | 278 ++++++++++++ src/components/ChatWindow.tsx | 707 +++++++++++++++++++++++++++++ src/components/MarkdownMessage.tsx | 272 +++++++++++ src/services/chat-engine.ts | 358 +++++++++++++++ src/services/chat-store.ts | 98 ++++ src/services/file-utils.ts | 62 +++ src/services/rag-service.ts | 42 ++ 10 files changed, 2200 insertions(+) create mode 100644 src/app/api/chat/route.ts create mode 100644 src/components/AIInput.tsx create mode 100644 src/components/ChatBot.tsx create mode 100644 src/components/ChatSidebar.tsx create mode 100644 src/components/ChatWindow.tsx create mode 100644 src/components/MarkdownMessage.tsx create mode 100644 src/services/chat-engine.ts create mode 100644 src/services/chat-store.ts create mode 100644 src/services/file-utils.ts create mode 100644 src/services/rag-service.ts diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 0000000..4ae8f01 --- /dev/null +++ b/src/app/api/chat/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import { + callLiveModel, + generateMockReply, + isLiveConfigPresent, + type ChatContext, + type ChatMessage, +} from '../../../services/chat-engine'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface RequestBody { + messages?: ChatMessage[]; + context?: ChatContext; +} + +export async function POST(req: Request) { + let body: RequestBody; + try { + body = (await req.json()) as RequestBody; + } catch { + return NextResponse.json( + { error: 'Invalid JSON body', mode: 'error' }, + { status: 400 } + ); + } + + const messages = Array.isArray(body.messages) ? body.messages : []; + const context = body.context ?? {}; + const lastUser = [...messages].reverse().find((m) => m.role === 'user'); + const userQuery = lastUser?.content ?? ''; + + if (!isLiveConfigPresent()) { + const result = generateMockReply(userQuery, messages, context); + return NextResponse.json(result); + } + + try { + const content = await callLiveModel(messages, context, { + apiKey: process.env.GEMINI_API_KEY as string, + baseUrl: process.env.REQUESTY_BASE_URL as string, + model: process.env.PATCHPILOT_MODEL || 'vertex/gemini-3-flash-preview', + }); + return NextResponse.json({ content, mode: 'live' }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[PatchPilot] Live model failed, falling back to mock:', message); + const fallback = generateMockReply(userQuery, messages, context); + return NextResponse.json({ ...fallback, mode: 'mock-fallback' }); + } +} + +export async function GET() { + return NextResponse.json({ + status: 'ok', + live: isLiveConfigPresent(), + runtime: 'nodejs', + }); +} diff --git a/src/components/AIInput.tsx b/src/components/AIInput.tsx new file mode 100644 index 0000000..df00289 --- /dev/null +++ b/src/components/AIInput.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { + Box, + IconButton, + Tooltip, + CircularProgress, + styled, + alpha, +} from '@mui/material'; +import { Send, Paperclip, Mic, X, Sparkles } from 'lucide-react'; +import { useI18n } from '../services/i18n'; + +const Shell = styled('div')(({ theme }) => ({ + position: 'relative', + width: '100%', + borderRadius: 20, + background: + theme.palette.mode === 'light' + ? theme.palette.background.paper + : alpha(theme.palette.common.white, 0.04), + border: `1px solid ${theme.palette.divider}`, + boxShadow: + theme.palette.mode === 'light' + ? '0 1px 2px rgba(15,23,42,0.04), 0 6px 18px -8px rgba(15,23,42,0.10)' + : '0 1px 2px rgba(0,0,0,0.3), 0 6px 18px -8px rgba(0,0,0,0.5)', + transition: 'border-color 0.18s ease, box-shadow 0.18s ease', + '&:focus-within': { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 4px ${alpha(theme.palette.primary.main, 0.18)}, 0 8px 24px -8px ${alpha( + theme.palette.primary.main, + 0.25 + )}`, + }, +})); + +const TextArea = styled('textarea')(({ theme }) => ({ + width: '100%', + minHeight: 28, + maxHeight: 220, + border: 'none', + outline: 'none', + resize: 'none', + background: 'transparent', + color: theme.palette.text.primary, + fontFamily: 'inherit', + fontSize: '1rem', + lineHeight: 1.55, + padding: 0, + '&::placeholder': { + color: theme.palette.text.secondary, + opacity: 0.7, + }, +})); + +interface AIInputProps { + onSend: (message: string, files?: File[]) => void; + isLoading?: boolean; + /** Compact = no top padding, smaller font (for chat footer) */ + compact?: boolean; +} + +export default function AIInput({ onSend, isLoading, compact }: AIInputProps) { + const [input, setInput] = useState(''); + const [files, setFiles] = useState([]); + const fileInputRef = useRef(null); + const textareaRef = useRef(null); + const { t } = useI18n(); + + const canSend = (input.trim().length > 0 || files.length > 0) && !isLoading; + + const autoGrow = () => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = Math.min(220, el.scrollHeight) + 'px'; + }; + + const handleSend = () => { + if (!canSend) return; + onSend(input.trim(), files); + setInput(''); + setFiles([]); + if (textareaRef.current) textareaRef.current.style.height = 'auto'; + }; + + const handleFiles = (e: React.ChangeEvent) => { + if (!e.target.files) return; + setFiles((prev) => [...prev, ...Array.from(e.target.files!)].slice(0, 5)); + e.target.value = ''; + }; + + const removeFile = (idx: number) => + setFiles((prev) => prev.filter((_, i) => i !== idx)); + + return ( + + + {files.length > 0 && ( + + {files.map((file, idx) => ( + + + {file.name} + + removeFile(idx)} + sx={{ color: 'white', p: 0.25 }} + > + + + + ))} + + )} + + +