Files
personal-ai-assistant/frontend/src/components/admin/context-editor.tsx
dolgolyov.alexei 70469beef8 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>
2026-03-19 12:38:30 +03:00

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