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:
2026-03-19 12:38:30 +03:00
parent 7c752cae6b
commit 70469beef8
39 changed files with 4168 additions and 47 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -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
View 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;
}

View File

@@ -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
View 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");
}
});
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View 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,
};
}

View 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 />;
}

View 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>
);
}

View File

@@ -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 /> },
],
},
],

View 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 }),
}));