Phase 2: Chat & AI Core — Claude API streaming, chat UI, admin context
Backend: - Chat, Message, ContextFile models + Alembic migration - Chat CRUD with per-user limit enforcement (max_chats) - SSE streaming endpoint: saves user message, streams Claude response, saves assistant message with token usage metadata - Context assembly: primary context file + conversation history - Admin context CRUD (GET/PUT with version tracking) - Anthropic SDK integration with async streaming - Chat ownership isolation (users can't access each other's chats) Frontend: - Chat page with sidebar chat list + main chat window - Real-time SSE streaming via fetch + ReadableStream - Message bubbles with Markdown rendering (react-markdown) - Auto-growing message input (Enter to send, Shift+Enter newline) - Zustand chat store for streaming state management - Admin primary context editor with unsaved changes warning - Updated routing: /chat, /chat/:chatId, /admin/context - Enabled Chat and Admin sidebar navigation - English + Russian translations for all new UI Infrastructure: - nginx: disabled proxy buffering for SSE support - Added ANTHROPIC_API_KEY and CLAUDE_MODEL to config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2299
frontend/package-lock.json
generated
2299
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,30 +9,32 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.60.0",
|
||||
"axios": "^1.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"i18next": "^24.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-http-backend": "^3.0.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"axios": "^1.7.0",
|
||||
"zustand": "^5.0.0",
|
||||
"@tanstack/react-query": "^5.60.0",
|
||||
"i18next": "^24.0.0",
|
||||
"react-i18next": "^15.1.0",
|
||||
"i18next-http-backend": "^3.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"clsx": "^2.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"sonner": "^1.7.0"
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0"
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,28 @@
|
||||
"welcome": "Welcome, {{name}}",
|
||||
"subtitle": "Your personal AI health assistant"
|
||||
},
|
||||
"chat": {
|
||||
"new_chat": "New Chat",
|
||||
"no_chats": "No chats yet. Create one to get started.",
|
||||
"no_messages": "Start a conversation...",
|
||||
"select_chat": "Select a chat to start messaging",
|
||||
"type_message": "Type a message...",
|
||||
"send": "Send",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Unarchive",
|
||||
"delete_confirm": "Are you sure you want to delete this chat?",
|
||||
"limit_reached": "Chat limit reached",
|
||||
"streaming": "AI is thinking..."
|
||||
},
|
||||
"admin": {
|
||||
"context_editor": "Primary Context Editor",
|
||||
"context_placeholder": "Enter the system prompt for the AI assistant...",
|
||||
"save": "Save",
|
||||
"saved": "Saved",
|
||||
"unsaved_changes": "Unsaved changes",
|
||||
"version": "Version",
|
||||
"characters": "characters"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
|
||||
@@ -42,6 +42,28 @@
|
||||
"welcome": "Добро пожаловать, {{name}}",
|
||||
"subtitle": "Ваш персональный ИИ-ассистент по здоровью"
|
||||
},
|
||||
"chat": {
|
||||
"new_chat": "Новый чат",
|
||||
"no_chats": "Пока нет чатов. Создайте первый.",
|
||||
"no_messages": "Начните разговор...",
|
||||
"select_chat": "Выберите чат для начала общения",
|
||||
"type_message": "Введите сообщение...",
|
||||
"send": "Отправить",
|
||||
"archive": "Архивировать",
|
||||
"unarchive": "Разархивировать",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить этот чат?",
|
||||
"limit_reached": "Достигнут лимит чатов",
|
||||
"streaming": "ИИ думает..."
|
||||
},
|
||||
"admin": {
|
||||
"context_editor": "Редактор основного контекста",
|
||||
"context_placeholder": "Введите системный промпт для ИИ-ассистента...",
|
||||
"save": "Сохранить",
|
||||
"saved": "Сохранено",
|
||||
"unsaved_changes": "Несохранённые изменения",
|
||||
"version": "Версия",
|
||||
"characters": "символов"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"error": "Произошла ошибка",
|
||||
|
||||
21
frontend/src/api/admin.ts
Normal file
21
frontend/src/api/admin.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import api from "./client";
|
||||
|
||||
export interface ContextFile {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
version: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function getPrimaryContext(): Promise<ContextFile | null> {
|
||||
const { data } = await api.get<ContextFile | null>("/admin/context");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updatePrimaryContext(
|
||||
content: string
|
||||
): Promise<ContextFile> {
|
||||
const { data } = await api.put<ContextFile>("/admin/context", { content });
|
||||
return data;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export interface UserResponse {
|
||||
full_name: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
max_chats: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
147
frontend/src/api/chats.ts
Normal file
147
frontend/src/api/chats.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import api from "./client";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
skill_id: string | null;
|
||||
is_archived: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
chat_id: string;
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ChatListResponse {
|
||||
chats: Chat[];
|
||||
}
|
||||
|
||||
export interface MessageListResponse {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export async function createChat(title?: string): Promise<Chat> {
|
||||
const { data } = await api.post<Chat>("/chats/", { title });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getChats(archived?: boolean): Promise<Chat[]> {
|
||||
const params = archived !== undefined ? { archived } : {};
|
||||
const { data } = await api.get<ChatListResponse>("/chats/", { params });
|
||||
return data.chats;
|
||||
}
|
||||
|
||||
export async function getChat(chatId: string): Promise<Chat> {
|
||||
const { data } = await api.get<Chat>(`/chats/${chatId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateChat(
|
||||
chatId: string,
|
||||
updates: { title?: string; is_archived?: boolean }
|
||||
): Promise<Chat> {
|
||||
const { data } = await api.patch<Chat>(`/chats/${chatId}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteChat(chatId: string): Promise<void> {
|
||||
await api.delete(`/chats/${chatId}`);
|
||||
}
|
||||
|
||||
export async function getMessages(
|
||||
chatId: string,
|
||||
limit = 50,
|
||||
before?: string
|
||||
): Promise<Message[]> {
|
||||
const params: Record<string, unknown> = { limit };
|
||||
if (before) params.before = before;
|
||||
const { data } = await api.get<MessageListResponse>(
|
||||
`/chats/${chatId}/messages`,
|
||||
{ params }
|
||||
);
|
||||
return data.messages;
|
||||
}
|
||||
|
||||
export interface SSECallbacks {
|
||||
onStart?: (messageId: string) => void;
|
||||
onDelta?: (delta: string) => void;
|
||||
onComplete?: (messageId: string, metadata: Record<string, unknown>) => void;
|
||||
onError?: (detail: string) => void;
|
||||
}
|
||||
|
||||
export function sendMessage(
|
||||
chatId: string,
|
||||
content: string,
|
||||
callbacks: SSECallbacks,
|
||||
signal?: AbortSignal
|
||||
): void {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
|
||||
fetch(`/api/v1/chats/${chatId}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: "Request failed" }));
|
||||
callbacks.onError?.(err.detail || "Request failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let currentEvent = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith("data: ")) {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
switch (currentEvent) {
|
||||
case "message_start":
|
||||
callbacks.onStart?.(data.message_id);
|
||||
break;
|
||||
case "content_delta":
|
||||
callbacks.onDelta?.(data.delta);
|
||||
break;
|
||||
case "message_end":
|
||||
callbacks.onComplete?.(data.message_id, data.metadata);
|
||||
break;
|
||||
case "error":
|
||||
callbacks.onError?.(data.detail);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") {
|
||||
callbacks.onError?.(err.message || "Network error");
|
||||
}
|
||||
});
|
||||
}
|
||||
92
frontend/src/components/admin/context-editor.tsx
Normal file
92
frontend/src/components/admin/context-editor.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getPrimaryContext, updatePrimaryContext } from "@/api/admin";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
export function ContextEditor() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [content, setContent] = useState("");
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["admin-context"],
|
||||
queryFn: getPrimaryContext,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
setContent(query.data.content);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (text: string) => updatePrimaryContext(text),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-context"] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setContent(value);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
mutation.mutate(content);
|
||||
};
|
||||
|
||||
// Warn on unsaved changes
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (hasChanges) e.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [hasChanges]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("admin.context_editor")}
|
||||
</h1>
|
||||
{query.data && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.version")}: {query.data.version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-amber-500">{t("admin.unsaved_changes")}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || mutation.isPending}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{mutation.isPending ? t("common.loading") : t("admin.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
rows={20}
|
||||
className="w-full resize-y rounded-lg border border-input bg-background px-4 py-3 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder={t("admin.context_placeholder")}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{content.length.toLocaleString()} {t("admin.characters")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/chat/chat-list.tsx
Normal file
84
frontend/src/components/chat/chat-list.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Plus, Archive, Trash2, MessageSquare } from "lucide-react";
|
||||
import { useChatStore } from "@/stores/chat-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatListProps {
|
||||
onCreateChat: () => void;
|
||||
onDeleteChat: (chatId: string) => void;
|
||||
onArchiveChat: (chatId: string, archived: boolean) => void;
|
||||
limitReached?: boolean;
|
||||
}
|
||||
|
||||
export function ChatList({
|
||||
onCreateChat,
|
||||
onDeleteChat,
|
||||
onArchiveChat,
|
||||
limitReached,
|
||||
}: ChatListProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { chats, currentChatId } = useChatStore();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b p-3">
|
||||
<h2 className="text-sm font-semibold">{t("layout.chats")}</h2>
|
||||
<button
|
||||
onClick={onCreateChat}
|
||||
disabled={limitReached}
|
||||
title={limitReached ? t("chat.limit_reached") : t("chat.new_chat")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{chats.length === 0 && (
|
||||
<p className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
{t("chat.no_chats")}
|
||||
</p>
|
||||
)}
|
||||
{chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
onClick={() => navigate(`/chat/${chat.id}`)}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-lg px-3 py-2 text-sm cursor-pointer transition-colors",
|
||||
chat.id === currentChatId
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{chat.title}</span>
|
||||
<div className="hidden gap-1 group-hover:flex">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onArchiveChat(chat.id, !chat.is_archived);
|
||||
}}
|
||||
className="rounded p-1 hover:bg-accent"
|
||||
title={chat.is_archived ? t("chat.unarchive") : t("chat.archive")}
|
||||
>
|
||||
<Archive className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteChat(chat.id);
|
||||
}}
|
||||
className="rounded p-1 hover:bg-destructive/10 hover:text-destructive"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/chat/chat-window.tsx
Normal file
57
frontend/src/components/chat/chat-window.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MessageBubble } from "./message-bubble";
|
||||
import { MessageInput } from "./message-input";
|
||||
import { useChatStore } from "@/stores/chat-store";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
|
||||
interface ChatWindowProps {
|
||||
onSendMessage: (content: string) => void;
|
||||
}
|
||||
|
||||
export function ChatWindow({ onSendMessage }: ChatWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { messages, isStreaming, streamingContent, currentChatId } = useChatStore();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
if (!currentChatId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<MessageSquare className="h-12 w-12 mb-4 opacity-30" />
|
||||
<p className="text-lg">{t("chat.select_chat")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">{t("chat.no_messages")}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
role={msg.role as "user" | "assistant"}
|
||||
content={msg.content}
|
||||
/>
|
||||
))}
|
||||
{isStreaming && streamingContent && (
|
||||
<MessageBubble
|
||||
role="assistant"
|
||||
content={streamingContent}
|
||||
isStreaming
|
||||
/>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<MessageInput onSend={onSendMessage} disabled={isStreaming} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/chat/message-bubble.tsx
Normal file
46
frontend/src/components/chat/message-bubble.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { User, Bot } from "lucide-react";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function MessageBubble({ role, content, isStreaming }: MessageBubbleProps) {
|
||||
const isUser = role === "user";
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
isUser ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isUser ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-xl px-4 py-3 text-sm",
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{content}</p>
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block h-4 w-1 animate-pulse bg-foreground/50 ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
frontend/src/components/chat/message-input.tsx
Normal file
61
frontend/src/components/chat/message-input.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendHorizonal } from "lucide-react";
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (content: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MessageInput({ onSend, disabled }: MessageInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed);
|
||||
setValue("");
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
}, [value, disabled, onSend]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setValue(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + "px";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2 border-t bg-card p-4">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("chat.type_message")}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border border-input bg-background px-3 py-2.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
maxLength={50000}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !value.trim()}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ key: "dashboard", to: "/", icon: LayoutDashboard, enabled: true },
|
||||
{ key: "chats", to: "/chat", icon: MessageSquare, enabled: false },
|
||||
{ key: "chats", to: "/chat", icon: MessageSquare, enabled: true },
|
||||
{ key: "documents", to: "/documents", icon: FileText, enabled: false },
|
||||
{ key: "memory", to: "/memory", icon: Brain, enabled: false },
|
||||
{ key: "notifications", to: "/notifications", icon: Bell, enabled: false },
|
||||
@@ -83,15 +83,21 @@ export function Sidebar() {
|
||||
|
||||
{user?.role === "admin" && (
|
||||
<div className="border-t p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground/50 cursor-not-allowed",
|
||||
!sidebarOpen && "justify-center px-2"
|
||||
)}
|
||||
<NavLink
|
||||
to="/admin/context"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
!sidebarOpen && "justify-center px-2"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Shield className="h-5 w-5 shrink-0" />
|
||||
{sidebarOpen && <span>{t("layout.admin")}</span>}
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
168
frontend/src/hooks/use-chat.ts
Normal file
168
frontend/src/hooks/use-chat.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useChatStore } from "@/stores/chat-store";
|
||||
import {
|
||||
getChats,
|
||||
getMessages,
|
||||
createChat,
|
||||
updateChat,
|
||||
deleteChat,
|
||||
sendMessage,
|
||||
type Message,
|
||||
} from "@/api/chats";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
export function useChat() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const {
|
||||
currentChatId,
|
||||
setChats,
|
||||
setCurrentChat,
|
||||
addChat,
|
||||
removeChat,
|
||||
updateChatInList,
|
||||
setMessages,
|
||||
appendMessage,
|
||||
setStreaming,
|
||||
appendStreamingContent,
|
||||
finalizeStreamingMessage,
|
||||
clearStreamingContent,
|
||||
} = useChatStore();
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Fetch chats
|
||||
const chatsQuery = useQuery({
|
||||
queryKey: ["chats"],
|
||||
queryFn: () => getChats(false),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (chatsQuery.data) {
|
||||
setChats(chatsQuery.data);
|
||||
}
|
||||
}, [chatsQuery.data, setChats]);
|
||||
|
||||
// Fetch messages for current chat
|
||||
const messagesQuery = useQuery({
|
||||
queryKey: ["messages", currentChatId],
|
||||
queryFn: () => getMessages(currentChatId!, 200),
|
||||
enabled: !!currentChatId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesQuery.data) {
|
||||
setMessages(messagesQuery.data);
|
||||
}
|
||||
}, [messagesQuery.data, setMessages]);
|
||||
|
||||
// Create chat
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (title?: string) => createChat(title),
|
||||
onSuccess: (chat) => {
|
||||
addChat(chat);
|
||||
queryClient.invalidateQueries({ queryKey: ["chats"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete chat
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (chatId: string) => deleteChat(chatId),
|
||||
onSuccess: (_, chatId) => {
|
||||
removeChat(chatId);
|
||||
if (currentChatId === chatId) {
|
||||
setCurrentChat(null);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["chats"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Archive/unarchive
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: ({ chatId, archived }: { chatId: string; archived: boolean }) =>
|
||||
updateChat(chatId, { is_archived: archived }),
|
||||
onSuccess: (chat) => {
|
||||
if (chat.is_archived) {
|
||||
removeChat(chat.id);
|
||||
} else {
|
||||
updateChatInList(chat);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["chats"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Send message with SSE
|
||||
const handleSendMessage = useCallback(
|
||||
(content: string) => {
|
||||
if (!currentChatId) return;
|
||||
|
||||
// Add user message optimistically
|
||||
const tempUserMsg: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
chat_id: currentChatId,
|
||||
role: "user",
|
||||
content,
|
||||
metadata: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
appendMessage(tempUserMsg);
|
||||
setStreaming(true);
|
||||
|
||||
// Abort previous stream if any
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
sendMessage(currentChatId, content, {
|
||||
onDelta: (delta) => appendStreamingContent(delta),
|
||||
onComplete: (messageId, metadata) => {
|
||||
const finalContent = useChatStore.getState().streamingContent;
|
||||
finalizeStreamingMessage(messageId, finalContent, metadata);
|
||||
queryClient.invalidateQueries({ queryKey: ["chats"] });
|
||||
},
|
||||
onError: (detail) => {
|
||||
clearStreamingContent();
|
||||
console.error("SSE error:", detail);
|
||||
},
|
||||
}, controller.signal);
|
||||
},
|
||||
[
|
||||
currentChatId,
|
||||
appendMessage,
|
||||
setStreaming,
|
||||
appendStreamingContent,
|
||||
finalizeStreamingMessage,
|
||||
clearStreamingContent,
|
||||
queryClient,
|
||||
]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cleanup on chat switch
|
||||
useEffect(() => {
|
||||
abortRef.current?.abort();
|
||||
clearStreamingContent();
|
||||
}, [currentChatId, clearStreamingContent]);
|
||||
|
||||
const limitReached = chatsQuery.data
|
||||
? chatsQuery.data.length >= (user?.max_chats ?? 10) && user?.role !== "admin"
|
||||
: false;
|
||||
|
||||
return {
|
||||
chatsQuery,
|
||||
messagesQuery,
|
||||
createChat: createMutation.mutateAsync,
|
||||
deleteChat: deleteMutation.mutate,
|
||||
archiveChat: (chatId: string, archived: boolean) =>
|
||||
archiveMutation.mutate({ chatId, archived }),
|
||||
sendMessage: handleSendMessage,
|
||||
limitReached,
|
||||
};
|
||||
}
|
||||
13
frontend/src/pages/admin/context.tsx
Normal file
13
frontend/src/pages/admin/context.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { ContextEditor } from "@/components/admin/context-editor";
|
||||
|
||||
export function AdminContextPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <ContextEditor />;
|
||||
}
|
||||
43
frontend/src/pages/chat.tsx
Normal file
43
frontend/src/pages/chat.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ChatList } from "@/components/chat/chat-list";
|
||||
import { ChatWindow } from "@/components/chat/chat-window";
|
||||
import { useChatStore } from "@/stores/chat-store";
|
||||
import { useChat } from "@/hooks/use-chat";
|
||||
|
||||
export function ChatPage() {
|
||||
const { chatId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const setCurrentChat = useChatStore((s) => s.setCurrentChat);
|
||||
const { createChat, deleteChat, archiveChat, sendMessage, limitReached } = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentChat(chatId || null);
|
||||
}, [chatId, setCurrentChat]);
|
||||
|
||||
const handleCreateChat = async () => {
|
||||
const chat = await createChat(undefined);
|
||||
navigate(`/chat/${chat.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteChat = (id: string) => {
|
||||
deleteChat(id);
|
||||
if (chatId === id) navigate("/chat");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full -m-6">
|
||||
<div className="w-64 shrink-0 border-r bg-card">
|
||||
<ChatList
|
||||
onCreateChat={handleCreateChat}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onArchiveChat={archiveChat}
|
||||
limitReached={limitReached}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<ChatWindow onSendMessage={sendMessage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { LoginPage } from "@/pages/login";
|
||||
import { RegisterPage } from "@/pages/register";
|
||||
import { DashboardPage } from "@/pages/dashboard";
|
||||
import { ChatPage } from "@/pages/chat";
|
||||
import { AdminContextPage } from "@/pages/admin/context";
|
||||
import { NotFoundPage } from "@/pages/not-found";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
@@ -22,6 +24,9 @@ export const router = createBrowserRouter([
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: "chat", element: <ChatPage /> },
|
||||
{ path: "chat/:chatId", element: <ChatPage /> },
|
||||
{ path: "admin/context", element: <AdminContextPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
60
frontend/src/stores/chat-store.ts
Normal file
60
frontend/src/stores/chat-store.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { create } from "zustand";
|
||||
import type { Chat, Message } from "@/api/chats";
|
||||
|
||||
interface ChatState {
|
||||
chats: Chat[];
|
||||
currentChatId: string | null;
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
setChats: (chats: Chat[]) => void;
|
||||
setCurrentChat: (chatId: string | null) => void;
|
||||
addChat: (chat: Chat) => void;
|
||||
removeChat: (chatId: string) => void;
|
||||
updateChatInList: (chat: Chat) => void;
|
||||
setMessages: (messages: Message[]) => void;
|
||||
appendMessage: (message: Message) => void;
|
||||
setStreaming: (streaming: boolean) => void;
|
||||
appendStreamingContent: (delta: string) => void;
|
||||
finalizeStreamingMessage: (messageId: string, content: string, metadata: Record<string, unknown> | null) => void;
|
||||
clearStreamingContent: () => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()((set) => ({
|
||||
chats: [],
|
||||
currentChatId: null,
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
streamingContent: "",
|
||||
|
||||
setChats: (chats) => set({ chats }),
|
||||
setCurrentChat: (chatId) => set({ currentChatId: chatId, messages: [], streamingContent: "" }),
|
||||
addChat: (chat) => set((s) => ({ chats: [chat, ...s.chats] })),
|
||||
removeChat: (chatId) => set((s) => ({ chats: s.chats.filter((c) => c.id !== chatId) })),
|
||||
updateChatInList: (chat) =>
|
||||
set((s) => ({
|
||||
chats: s.chats.map((c) => (c.id === chat.id ? chat : c)),
|
||||
})),
|
||||
setMessages: (messages) => set({ messages }),
|
||||
appendMessage: (message) => set((s) => ({ messages: [...s.messages, message] })),
|
||||
setStreaming: (streaming) => set({ isStreaming: streaming }),
|
||||
appendStreamingContent: (delta) =>
|
||||
set((s) => ({ streamingContent: s.streamingContent + delta })),
|
||||
finalizeStreamingMessage: (messageId, content, metadata) =>
|
||||
set((s) => ({
|
||||
messages: [
|
||||
...s.messages,
|
||||
{
|
||||
id: messageId,
|
||||
chat_id: s.currentChatId || "",
|
||||
role: "assistant" as const,
|
||||
content,
|
||||
metadata,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
streamingContent: "",
|
||||
isStreaming: false,
|
||||
})),
|
||||
clearStreamingContent: () => set({ streamingContent: "", isStreaming: false }),
|
||||
}));
|
||||
Reference in New Issue
Block a user