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:
@@ -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;
|
||||
|
||||
93
frontend/src/api/skills.ts
Normal file
93
frontend/src/api/skills.ts
Normal 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}`);
|
||||
}
|
||||
14
frontend/src/api/user-context.ts
Normal file
14
frontend/src/api/user-context.ts
Normal 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;
|
||||
}
|
||||
97
frontend/src/components/admin/skill-editor.tsx
Normal file
97
frontend/src/components/admin/skill-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
54
frontend/src/components/chat/skill-selector.tsx
Normal file
54
frontend/src/components/chat/skill-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
114
frontend/src/pages/admin/skills.tsx
Normal file
114
frontend/src/pages/admin/skills.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
76
frontend/src/pages/profile/context.tsx
Normal file
76
frontend/src/pages/profile/context.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/pages/skills.tsx
Normal file
108
frontend/src/pages/skills.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user