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