Phase 3: Skills & Context — skill system, personal context, context layering

Backend:
- Skill model + migration (with FK on chats.skill_id)
- Personal + general skill CRUD services with access isolation
- Admin skill CRUD endpoints (POST/GET/PATCH/DELETE /admin/skills)
- User skill CRUD endpoints (POST/GET/PATCH/DELETE /skills/)
- Personal context GET/PUT at /users/me/context
- Extended context assembly: primary + personal context + skill prompt
- Chat creation/update now accepts skill_id with validation

Frontend:
- Skill selector dropdown in chat header (grouped: general + personal)
- Reusable skill editor form component
- Admin skills management page (/admin/skills)
- Personal skills page (/skills)
- Personal context editor page (/profile/context)
- Updated sidebar: Skills, My Context nav items + admin skills link
- English + Russian translations for all skill/context UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:55:02 +03:00
parent 70469beef8
commit 03afb7a075
33 changed files with 1387 additions and 62 deletions

View File

@@ -0,0 +1,114 @@
import { useState } from "react";
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/auth-store";
import {
getGeneralSkills,
createGeneralSkill,
updateGeneralSkill,
deleteGeneralSkill,
type Skill,
} from "@/api/skills";
import { SkillEditor } from "@/components/admin/skill-editor";
import { Plus, Pencil, Trash2, Sparkles } from "lucide-react";
export function AdminSkillsPage() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
const queryClient = useQueryClient();
const [editing, setEditing] = useState<Skill | null>(null);
const [creating, setCreating] = useState(false);
if (user?.role !== "admin") return <Navigate to="/" replace />;
const { data: skills = [] } = useQuery({
queryKey: ["admin-skills"],
queryFn: getGeneralSkills,
});
const createMut = useMutation({
mutationFn: createGeneralSkill,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
setCreating(false);
},
});
const updateMut = useMutation({
mutationFn: ({ id, ...data }: { id: string } & Record<string, string>) =>
updateGeneralSkill(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
setEditing(null);
},
});
const deleteMut = useMutation({
mutationFn: deleteGeneralSkill,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-skills"] }),
});
if (creating || editing) {
return (
<div className="max-w-2xl space-y-4">
<h1 className="text-2xl font-semibold">
{creating ? t("skills.create_general") : t("skills.edit_general")}
</h1>
<SkillEditor
skill={editing}
onSave={(data) =>
editing
? updateMut.mutate({ id: editing.id, ...data })
: createMut.mutate(data)
}
onCancel={() => { setCreating(false); setEditing(null); }}
loading={createMut.isPending || updateMut.isPending}
/>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{t("skills.general_skills")}</h1>
<button
onClick={() => setCreating(true)}
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 transition-colors"
>
<Plus className="h-4 w-4" /> {t("common.create")}
</button>
</div>
{skills.length === 0 && (
<p className="text-muted-foreground py-8 text-center">{t("skills.no_skills")}</p>
)}
<div className="space-y-2">
{skills.map((skill) => (
<div key={skill.id} className="flex items-center gap-3 rounded-lg border bg-card p-4">
<Sparkles className="h-5 w-5 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium">{skill.name}</p>
{skill.description && (
<p className="text-sm text-muted-foreground truncate">{skill.description}</p>
)}
</div>
<div className="flex gap-1">
<button onClick={() => setEditing(skill)} className="rounded p-2 hover:bg-accent transition-colors">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => deleteMut.mutate(skill.id)}
className="rounded p-2 hover:bg-destructive/10 hover:text-destructive transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
);
}