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>
93 lines
3.0 KiB
TypeScript
93 lines
3.0 KiB
TypeScript
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>
|
|
);
|
|
}
|