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:
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user