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

@@ -36,7 +36,8 @@
"admin": "Admin",
"users": "Users",
"context": "Context",
"skills": "Skills"
"skills": "Skills",
"personal_context": "My Context"
},
"dashboard": {
"welcome": "Welcome, {{name}}",
@@ -64,6 +65,31 @@
"version": "Version",
"characters": "characters"
},
"skills": {
"my_skills": "My Skills",
"general_skills": "General Skills",
"no_skills": "No skills yet.",
"no_personal_skills": "You haven't created any personal skills yet.",
"create_personal": "Create Personal Skill",
"edit_personal": "Edit Personal Skill",
"create_general": "Create General Skill",
"edit_general": "Edit General Skill",
"name": "Name",
"name_placeholder": "e.g. Cardiologist",
"description": "Description",
"description_placeholder": "Brief description of this specialist",
"system_prompt": "System Prompt",
"prompt_placeholder": "Instructions for the AI when using this skill...",
"icon": "Icon",
"no_skill": "No specialist",
"general": "General",
"personal": "Personal"
},
"personal_context": {
"title": "Personal Context",
"subtitle": "This context is added to all your AI conversations",
"placeholder": "Add personal information that the AI should know about you..."
},
"common": {
"loading": "Loading...",
"error": "An error occurred",

View File

@@ -36,7 +36,8 @@
"admin": "Администрирование",
"users": "Пользователи",
"context": "Контекст",
"skills": "Навыки"
"skills": "Навыки",
"personal_context": "Мой контекст"
},
"dashboard": {
"welcome": "Добро пожаловать, {{name}}",
@@ -64,6 +65,31 @@
"version": "Версия",
"characters": "символов"
},
"skills": {
"my_skills": "Мои навыки",
"general_skills": "Общие навыки",
"no_skills": "Навыков пока нет.",
"no_personal_skills": "Вы ещё не создали персональных навыков.",
"create_personal": "Создать персональный навык",
"edit_personal": "Редактировать навык",
"create_general": "Создать общий навык",
"edit_general": "Редактировать общий навык",
"name": "Название",
"name_placeholder": "напр. Кардиолог",
"description": "Описание",
"description_placeholder": "Краткое описание специалиста",
"system_prompt": "Системный промпт",
"prompt_placeholder": "Инструкции для ИИ при использовании этого навыка...",
"icon": "Иконка",
"no_skill": "Без специалиста",
"general": "Общие",
"personal": "Персональные"
},
"personal_context": {
"title": "Персональный контекст",
"subtitle": "Этот контекст добавляется ко всем вашим разговорам с ИИ",
"placeholder": "Добавьте личную информацию, которую ИИ должен знать о вас..."
},
"common": {
"loading": "Загрузка...",
"error": "Произошла ошибка",

View File

@@ -46,7 +46,7 @@ export async function getChat(chatId: string): Promise<Chat> {
export async function updateChat(
chatId: string,
updates: { title?: string; is_archived?: boolean }
updates: { title?: string; is_archived?: boolean; skill_id?: string }
): Promise<Chat> {
const { data } = await api.patch<Chat>(`/chats/${chatId}`, updates);
return data;

View File

@@ -0,0 +1,93 @@
import api from "./client";
export interface Skill {
id: string;
user_id: string | null;
name: string;
description: string | null;
system_prompt: string;
icon: string | null;
is_active: boolean;
sort_order: number;
created_at: string;
}
export interface SkillListResponse {
skills: Skill[];
}
export async function getSkills(includeGeneral = true): Promise<Skill[]> {
const { data } = await api.get<SkillListResponse>("/skills/", {
params: { include_general: includeGeneral },
});
return data.skills;
}
export async function getSkill(skillId: string): Promise<Skill> {
const { data } = await api.get<Skill>(`/skills/${skillId}`);
return data;
}
export async function createSkill(skill: {
name: string;
description?: string;
system_prompt: string;
icon?: string;
}): Promise<Skill> {
const { data } = await api.post<Skill>("/skills/", skill);
return data;
}
export async function updateSkill(
skillId: string,
updates: Partial<{
name: string;
description: string;
system_prompt: string;
icon: string;
is_active: boolean;
sort_order: number;
}>
): Promise<Skill> {
const { data } = await api.patch<Skill>(`/skills/${skillId}`, updates);
return data;
}
export async function deleteSkill(skillId: string): Promise<void> {
await api.delete(`/skills/${skillId}`);
}
// Admin skill functions
export async function getGeneralSkills(): Promise<Skill[]> {
const { data } = await api.get<SkillListResponse>("/admin/skills");
return data.skills;
}
export async function createGeneralSkill(skill: {
name: string;
description?: string;
system_prompt: string;
icon?: string;
}): Promise<Skill> {
const { data } = await api.post<Skill>("/admin/skills", skill);
return data;
}
export async function updateGeneralSkill(
skillId: string,
updates: Partial<{
name: string;
description: string;
system_prompt: string;
icon: string;
is_active: boolean;
sort_order: number;
}>
): Promise<Skill> {
const { data } = await api.patch<Skill>(`/admin/skills/${skillId}`, updates);
return data;
}
export async function deleteGeneralSkill(skillId: string): Promise<void> {
await api.delete(`/admin/skills/${skillId}`);
}

View File

@@ -0,0 +1,14 @@
import api from "./client";
import type { ContextFile } from "./admin";
export async function getPersonalContext(): Promise<ContextFile | null> {
const { data } = await api.get<ContextFile | null>("/users/me/context");
return data;
}
export async function updatePersonalContext(
content: string
): Promise<ContextFile> {
const { data } = await api.put<ContextFile>("/users/me/context", { content });
return data;
}

View File

@@ -0,0 +1,97 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { Skill } from "@/api/skills";
interface SkillEditorProps {
skill?: Skill | null;
onSave: (data: { name: string; description: string; system_prompt: string; icon: string }) => void;
onCancel: () => void;
loading?: boolean;
}
export function SkillEditor({ skill, onSave, onCancel, loading }: SkillEditorProps) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [systemPrompt, setSystemPrompt] = useState("");
const [icon, setIcon] = useState("");
useEffect(() => {
if (skill) {
setName(skill.name);
setDescription(skill.description || "");
setSystemPrompt(skill.system_prompt);
setIcon(skill.icon || "");
}
}, [skill]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({ name, description, system_prompt: systemPrompt, icon });
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t("skills.name")}</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
maxLength={100}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder={t("skills.name_placeholder")}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t("skills.description")}</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder={t("skills.description_placeholder")}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t("skills.system_prompt")}</label>
<textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
required
rows={8}
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder={t("skills.prompt_placeholder")}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t("skills.icon")}</label>
<input
value={icon}
onChange={(e) => setIcon(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="e.g. heart, brain, stethoscope"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onCancel}
className="inline-flex h-9 items-center rounded-md border px-4 text-sm font-medium hover:bg-accent transition-colors"
>
{t("common.cancel")}
</button>
<button
type="submit"
disabled={loading || !name.trim() || !systemPrompt.trim()}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading ? t("common.loading") : t("common.save")}
</button>
</div>
</form>
);
}

View File

@@ -2,14 +2,17 @@ import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { MessageBubble } from "./message-bubble";
import { MessageInput } from "./message-input";
import { SkillSelector } from "./skill-selector";
import { useChatStore } from "@/stores/chat-store";
import { MessageSquare } from "lucide-react";
interface ChatWindowProps {
onSendMessage: (content: string) => void;
onChangeSkill?: (skillId: string | null) => void;
currentSkillId?: string | null;
}
export function ChatWindow({ onSendMessage }: ChatWindowProps) {
export function ChatWindow({ onSendMessage, onChangeSkill, currentSkillId }: ChatWindowProps) {
const { t } = useTranslation();
const { messages, isStreaming, streamingContent, currentChatId } = useChatStore();
const bottomRef = useRef<HTMLDivElement>(null);
@@ -29,6 +32,17 @@ export function ChatWindow({ onSendMessage }: ChatWindowProps) {
return (
<div className="flex h-full flex-col">
{onChangeSkill && (
<div className="flex items-center gap-2 border-b px-4 py-2">
<div className="w-48">
<SkillSelector
value={currentSkillId ?? null}
onChange={onChangeSkill}
disabled={isStreaming}
/>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && !isStreaming && (
<div className="flex h-full items-center justify-center">

View File

@@ -0,0 +1,54 @@
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { getSkills } from "@/api/skills";
import { Sparkles } from "lucide-react";
interface SkillSelectorProps {
value: string | null;
onChange: (skillId: string | null) => void;
disabled?: boolean;
}
export function SkillSelector({ value, onChange, disabled }: SkillSelectorProps) {
const { t } = useTranslation();
const { data: skills = [] } = useQuery({
queryKey: ["skills"],
queryFn: () => getSkills(true),
});
const generalSkills = skills.filter((s) => s.user_id === null);
const personalSkills = skills.filter((s) => s.user_id !== null);
return (
<div className="relative">
<select
value={value || ""}
onChange={(e) => onChange(e.target.value || null)}
disabled={disabled}
className="flex h-9 w-full items-center rounded-md border border-input bg-background px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 appearance-none pr-8"
>
<option value="">{t("skills.no_skill")}</option>
{generalSkills.length > 0 && (
<optgroup label={t("skills.general")}>
{generalSkills.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</optgroup>
)}
{personalSkills.length > 0 && (
<optgroup label={t("skills.personal")}>
{personalSkills.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</optgroup>
)}
</select>
<Sparkles className="absolute right-2 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none" />
</div>
);
}

View File

@@ -3,21 +3,30 @@ import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
MessageSquare,
Sparkles,
FileText,
Brain,
Bell,
Shield,
BookOpen,
} from "lucide-react";
import { useAuthStore } from "@/stores/auth-store";
import { useUIStore } from "@/stores/ui-store";
import { cn } from "@/lib/utils";
const navItems = [
{ key: "dashboard", to: "/", icon: LayoutDashboard, enabled: true },
{ key: "chats", to: "/chat", icon: MessageSquare, enabled: true },
{ key: "documents", to: "/documents", icon: FileText, enabled: false },
{ key: "memory", to: "/memory", icon: Brain, enabled: false },
{ key: "notifications", to: "/notifications", icon: Bell, enabled: false },
{ key: "dashboard", to: "/", icon: LayoutDashboard, enabled: true, end: true },
{ key: "chats", to: "/chat", icon: MessageSquare, enabled: true, end: false },
{ key: "skills", to: "/skills", icon: Sparkles, enabled: true, end: true },
{ key: "personal_context", to: "/profile/context", icon: BookOpen, enabled: true, end: true },
{ key: "documents", to: "/documents", icon: FileText, enabled: false, end: true },
{ key: "memory", to: "/memory", icon: Brain, enabled: false, end: true },
{ key: "notifications", to: "/notifications", icon: Bell, enabled: false, end: true },
];
const adminItems = [
{ key: "admin_context", to: "/admin/context", label: "layout.context" },
{ key: "admin_skills", to: "/admin/skills", label: "layout.skills" },
];
export function Sidebar() {
@@ -25,6 +34,15 @@ export function Sidebar() {
const user = useAuthStore((s) => s.user);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const linkClass = ({ isActive }: { isActive: boolean }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
!sidebarOpen && "justify-center px-2"
);
return (
<aside
className={cn(
@@ -33,10 +51,9 @@ export function Sidebar() {
)}
>
<div className="flex h-14 items-center border-b px-4">
{sidebarOpen && (
{sidebarOpen ? (
<span className="text-lg font-semibold text-primary">AI Assistant</span>
)}
{!sidebarOpen && (
) : (
<span className="text-lg font-semibold text-primary mx-auto">AI</span>
)}
</div>
@@ -60,20 +77,7 @@ export function Sidebar() {
}
return (
<NavLink
key={item.key}
to={item.to}
end
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
!sidebarOpen && "justify-center px-2"
)
}
>
<NavLink key={item.key} to={item.to} end={item.end} className={linkClass}>
<Icon className="h-5 w-5 shrink-0" />
{sidebarOpen && <span>{t(`layout.${item.key}`)}</span>}
</NavLink>
@@ -82,22 +86,18 @@ export function Sidebar() {
</nav>
{user?.role === "admin" && (
<div className="border-t p-2">
<NavLink
to="/admin/context"
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
!sidebarOpen && "justify-center px-2"
)
}
>
<Shield className="h-5 w-5 shrink-0" />
{sidebarOpen && <span>{t("layout.admin")}</span>}
</NavLink>
<div className="border-t p-2 space-y-1">
{sidebarOpen && (
<p className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{t("layout.admin")}
</p>
)}
{adminItems.map((item) => (
<NavLink key={item.key} to={item.to} className={linkClass}>
<Shield className="h-5 w-5 shrink-0" />
{sidebarOpen && <span>{t(item.label)}</span>}
</NavLink>
))}
</div>
)}
</aside>

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

View File

@@ -1,20 +1,34 @@
import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ChatList } from "@/components/chat/chat-list";
import { ChatWindow } from "@/components/chat/chat-window";
import { useChatStore } from "@/stores/chat-store";
import { useChat } from "@/hooks/use-chat";
import { updateChat } from "@/api/chats";
export function ChatPage() {
const { chatId } = useParams();
const navigate = useNavigate();
const setCurrentChat = useChatStore((s) => s.setCurrentChat);
const queryClient = useQueryClient();
const { currentChatId, chats, setCurrentChat, updateChatInList } = useChatStore();
const { createChat, deleteChat, archiveChat, sendMessage, limitReached } = useChat();
useEffect(() => {
setCurrentChat(chatId || null);
}, [chatId, setCurrentChat]);
const currentChat = chats.find((c) => c.id === currentChatId);
const skillMutation = useMutation({
mutationFn: ({ chatId, skillId }: { chatId: string; skillId: string | null }) =>
updateChat(chatId, { skill_id: skillId || undefined }),
onSuccess: (chat) => {
updateChatInList(chat);
queryClient.invalidateQueries({ queryKey: ["chats"] });
},
});
const handleCreateChat = async () => {
const chat = await createChat(undefined);
navigate(`/chat/${chat.id}`);
@@ -25,6 +39,12 @@ export function ChatPage() {
if (chatId === id) navigate("/chat");
};
const handleChangeSkill = (skillId: string | null) => {
if (currentChatId) {
skillMutation.mutate({ chatId: currentChatId, skillId });
}
};
return (
<div className="flex h-full -m-6">
<div className="w-64 shrink-0 border-r bg-card">
@@ -36,7 +56,11 @@ export function ChatPage() {
/>
</div>
<div className="flex-1">
<ChatWindow onSendMessage={sendMessage} />
<ChatWindow
onSendMessage={sendMessage}
onChangeSkill={handleChangeSkill}
currentSkillId={currentChat?.skill_id}
/>
</div>
</div>
);

View File

@@ -0,0 +1,76 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getPersonalContext, updatePersonalContext } from "@/api/user-context";
import { Save } from "lucide-react";
export function PersonalContextPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [content, setContent] = useState("");
const [hasChanges, setHasChanges] = useState(false);
const query = useQuery({
queryKey: ["personal-context"],
queryFn: getPersonalContext,
});
useEffect(() => {
if (query.data) {
setContent(query.data.content);
setHasChanges(false);
}
}, [query.data]);
const mutation = useMutation({
mutationFn: (text: string) => updatePersonalContext(text),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["personal-context"] });
setHasChanges(false);
},
});
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("personal_context.title")}
</h1>
<p className="text-sm text-muted-foreground">
{t("personal_context.subtitle")}
</p>
</div>
<div className="flex items-center gap-2">
{hasChanges && (
<span className="text-sm text-amber-500">{t("admin.unsaved_changes")}</span>
)}
<button
onClick={() => mutation.mutate(content)}
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("common.save")}
</button>
</div>
</div>
<textarea
value={content}
onChange={(e) => { setContent(e.target.value); setHasChanges(true); }}
rows={15}
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("personal_context.placeholder")}
/>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getSkills, createSkill, updateSkill, deleteSkill, type Skill } from "@/api/skills";
import { SkillEditor } from "@/components/admin/skill-editor";
import { Plus, Pencil, Trash2, Sparkles } from "lucide-react";
export function SkillsPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [editing, setEditing] = useState<Skill | null>(null);
const [creating, setCreating] = useState(false);
const { data: skills = [] } = useQuery({
queryKey: ["personal-skills"],
queryFn: () => getSkills(false),
});
const createMut = useMutation({
mutationFn: createSkill,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["personal-skills"] });
queryClient.invalidateQueries({ queryKey: ["skills"] });
setCreating(false);
},
});
const updateMut = useMutation({
mutationFn: ({ id, ...data }: { id: string } & Record<string, string>) =>
updateSkill(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["personal-skills"] });
queryClient.invalidateQueries({ queryKey: ["skills"] });
setEditing(null);
},
});
const deleteMut = useMutation({
mutationFn: deleteSkill,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["personal-skills"] });
queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
if (creating || editing) {
return (
<div className="max-w-2xl space-y-4">
<h1 className="text-2xl font-semibold">
{creating ? t("skills.create_personal") : t("skills.edit_personal")}
</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.my_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_personal_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>
);
}

View File

@@ -5,7 +5,10 @@ import { LoginPage } from "@/pages/login";
import { RegisterPage } from "@/pages/register";
import { DashboardPage } from "@/pages/dashboard";
import { ChatPage } from "@/pages/chat";
import { SkillsPage } from "@/pages/skills";
import { PersonalContextPage } from "@/pages/profile/context";
import { AdminContextPage } from "@/pages/admin/context";
import { AdminSkillsPage } from "@/pages/admin/skills";
import { NotFoundPage } from "@/pages/not-found";
export const router = createBrowserRouter([
@@ -26,7 +29,10 @@ export const router = createBrowserRouter([
{ index: true, element: <DashboardPage /> },
{ path: "chat", element: <ChatPage /> },
{ path: "chat/:chatId", element: <ChatPage /> },
{ path: "skills", element: <SkillsPage /> },
{ path: "profile/context", element: <PersonalContextPage /> },
{ path: "admin/context", element: <AdminContextPage /> },
{ path: "admin/skills", element: <AdminSkillsPage /> },
],
},
],