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

@@ -214,8 +214,8 @@ Daily scheduled job (APScheduler, 8 AM) reviews each user's memory + recent docs
- Summary: Chats + messages tables, chat CRUD, SSE streaming, Claude API integration, context assembly, frontend chat UI, admin context editor, chat limits
### Phase 3: Skills & Context
- **Status**: NOT STARTED
- [ ] Subplan created (`plans/phase-3-skills-context.md`)
- **Status**: IN PROGRESS
- [x] Subplan created (`plans/phase-3-skills-context.md`)
- [ ] Phase completed
- Summary: Skills + context_files tables, skills CRUD (general + personal), personal context CRUD, context layering, frontend skill selector + editors

View File

@@ -0,0 +1,46 @@
"""Create skills table and add FK on chats.skill_id
Revision ID: 003
Revises: 002
Create Date: 2026-03-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"skills",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("system_prompt", sa.Text, nullable=False),
sa.Column("icon", sa.String(50), nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
sa.Column("sort_order", sa.Integer, nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_foreign_key(
"fk_chats_skill_id",
"chats",
"skills",
["skill_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_chats_skill_id", "chats", type_="foreignkey")
op.drop_table("skills")

View File

@@ -1,17 +1,26 @@
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
from app.database import get_db
from app.models.user import User
from app.schemas.chat import ContextFileResponse, UpdateContextRequest
from app.services import context_service
from app.schemas.skill import (
CreateSkillRequest,
SkillListResponse,
SkillResponse,
UpdateSkillRequest,
)
from app.services import context_service, skill_service
router = APIRouter(prefix="/admin", tags=["admin"])
# --- Context ---
@router.get("/context", response_model=ContextFileResponse | None)
async def get_primary_context(
_admin: Annotated[User, Depends(require_admin)],
@@ -31,3 +40,44 @@ async def update_primary_context(
):
ctx = await context_service.upsert_primary_context(db, data.content, admin.id)
return ContextFileResponse.model_validate(ctx)
# --- Skills ---
@router.get("/skills", response_model=SkillListResponse)
async def list_general_skills(
_admin: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
skills = await skill_service.get_general_skills(db)
return SkillListResponse(skills=[SkillResponse.model_validate(s) for s in skills])
@router.post("/skills", response_model=SkillResponse, status_code=status.HTTP_201_CREATED)
async def create_general_skill(
data: CreateSkillRequest,
_admin: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
skill = await skill_service.create_general_skill(db, **data.model_dump())
return SkillResponse.model_validate(skill)
@router.patch("/skills/{skill_id}", response_model=SkillResponse)
async def update_general_skill(
skill_id: uuid.UUID,
data: UpdateSkillRequest,
_admin: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
skill = await skill_service.update_general_skill(db, skill_id, **data.model_dump(exclude_unset=True))
return SkillResponse.model_validate(skill)
@router.delete("/skills/{skill_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_general_skill(
skill_id: uuid.UUID,
_admin: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
await skill_service.delete_general_skill(db, skill_id)

View File

@@ -17,7 +17,7 @@ from app.schemas.chat import (
SendMessageRequest,
UpdateChatRequest,
)
from app.services import chat_service
from app.services import chat_service, skill_service
from app.services.ai_service import stream_ai_response
router = APIRouter(prefix="/chats", tags=["chats"])
@@ -29,7 +29,9 @@ async def create_chat(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
chat = await chat_service.create_chat(db, user, data.title)
if data.skill_id:
await skill_service.validate_skill_accessible(db, data.skill_id, user.id)
chat = await chat_service.create_chat(db, user, data.title, data.skill_id)
return ChatResponse.model_validate(chat)
@@ -60,7 +62,9 @@ async def update_chat(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
chat = await chat_service.update_chat(db, chat_id, user.id, data.title, data.is_archived)
if data.skill_id:
await skill_service.validate_skill_accessible(db, data.skill_id, user.id)
chat = await chat_service.update_chat(db, chat_id, user.id, data.title, data.is_archived, data.skill_id)
return ChatResponse.model_validate(chat)

View File

@@ -3,12 +3,16 @@ from fastapi import APIRouter
from app.api.v1.auth import router as auth_router
from app.api.v1.chats import router as chats_router
from app.api.v1.admin import router as admin_router
from app.api.v1.skills import router as skills_router
from app.api.v1.users import router as users_router
api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(auth_router)
api_v1_router.include_router(chats_router)
api_v1_router.include_router(admin_router)
api_v1_router.include_router(skills_router)
api_v1_router.include_router(users_router)
@api_v1_router.get("/health")

View File

@@ -0,0 +1,70 @@
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.database import get_db
from app.models.user import User
from app.schemas.skill import (
CreateSkillRequest,
SkillListResponse,
SkillResponse,
UpdateSkillRequest,
)
from app.services import skill_service
router = APIRouter(prefix="/skills", tags=["skills"])
@router.get("/", response_model=SkillListResponse)
async def list_skills(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
include_general: bool = Query(default=True),
):
skills = await skill_service.get_accessible_skills(db, user.id, include_general)
return SkillListResponse(skills=[SkillResponse.model_validate(s) for s in skills])
@router.post("/", response_model=SkillResponse, status_code=status.HTTP_201_CREATED)
async def create_skill(
data: CreateSkillRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
skill = await skill_service.create_personal_skill(db, user.id, **data.model_dump())
return SkillResponse.model_validate(skill)
@router.get("/{skill_id}", response_model=SkillResponse)
async def get_skill(
skill_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
skill = await skill_service.get_skill(db, skill_id, user.id)
return SkillResponse.model_validate(skill)
@router.patch("/{skill_id}", response_model=SkillResponse)
async def update_skill(
skill_id: uuid.UUID,
data: UpdateSkillRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
skill = await skill_service.update_personal_skill(
db, skill_id, user.id, **data.model_dump(exclude_unset=True)
)
return SkillResponse.model_validate(skill)
@router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_skill(
skill_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
await skill_service.delete_personal_skill(db, skill_id, user.id)

View File

@@ -0,0 +1,33 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.database import get_db
from app.models.user import User
from app.schemas.chat import ContextFileResponse, UpdateContextRequest
from app.services import context_service
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me/context", response_model=ContextFileResponse | None)
async def get_personal_context(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
ctx = await context_service.get_personal_context(db, user.id)
if not ctx:
return None
return ContextFileResponse.model_validate(ctx)
@router.put("/me/context", response_model=ContextFileResponse)
async def update_personal_context(
data: UpdateContextRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
ctx = await context_service.upsert_personal_context(db, user.id, data.content)
return ContextFileResponse.model_validate(ctx)

View File

@@ -3,5 +3,6 @@ from app.models.session import Session
from app.models.chat import Chat
from app.models.message import Message
from app.models.context_file import ContextFile
from app.models.skill import Skill
__all__ = ["User", "Session", "Chat", "Message", "ContextFile"]
__all__ = ["User", "Session", "Chat", "Message", "ContextFile", "Skill"]

View File

@@ -15,11 +15,14 @@ class Chat(Base):
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False, default="New Chat")
skill_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
skill_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("skills.id", ondelete="SET NULL"), nullable=True
)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
user: Mapped["User"] = relationship(back_populates="chats") # noqa: F821
skill: Mapped["Skill | None"] = relationship() # noqa: F821
messages: Mapped[list["Message"]] = relationship(back_populates="chat", cascade="all, delete-orphan") # noqa: F821

View File

@@ -0,0 +1,23 @@
import uuid
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Skill(Base):
__tablename__ = "skills"
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
icon: Mapped[str | None] = mapped_column(String(50), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
user: Mapped["User | None"] = relationship(back_populates="skills") # noqa: F821

View File

@@ -26,3 +26,4 @@ class User(Base):
sessions: Mapped[list["Session"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821
chats: Mapped[list["Chat"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821
skills: Mapped[list["Skill"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821

View File

@@ -6,11 +6,13 @@ from pydantic import BaseModel, Field
class CreateChatRequest(BaseModel):
title: str | None = None
skill_id: uuid.UUID | None = None
class UpdateChatRequest(BaseModel):
title: str | None = None
is_archived: bool | None = None
skill_id: uuid.UUID | None = None
class SendMessageRequest(BaseModel):

View File

@@ -0,0 +1,40 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class CreateSkillRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str | None = None
system_prompt: str = Field(min_length=1)
icon: str | None = None
is_active: bool = True
sort_order: int = 0
class UpdateSkillRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = None
system_prompt: str | None = Field(default=None, min_length=1)
icon: str | None = None
is_active: bool | None = None
sort_order: int | None = None
class SkillResponse(BaseModel):
id: uuid.UUID
user_id: uuid.UUID | None
name: str
description: str | None
system_prompt: str
icon: str | None
is_active: bool
sort_order: int
created_at: datetime
model_config = {"from_attributes": True}
class SkillListResponse(BaseModel):
skills: list[SkillResponse]

View File

@@ -9,31 +9,48 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.chat import Chat
from app.models.message import Message
from app.services.context_service import DEFAULT_SYSTEM_PROMPT, get_primary_context
from app.models.skill import Skill
from app.services.context_service import DEFAULT_SYSTEM_PROMPT, get_primary_context, get_personal_context
from app.services.chat_service import get_chat, save_message
client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
async def assemble_context(
db: AsyncSession, chat_id: uuid.UUID, user_message: str
db: AsyncSession, chat_id: uuid.UUID, user_id: uuid.UUID, user_message: str
) -> tuple[str, list[dict]]:
"""Assemble system prompt and messages for Claude API."""
system_parts = []
# 1. Primary context
ctx = await get_primary_context(db)
system_prompt = ctx.content if ctx and ctx.content.strip() else DEFAULT_SYSTEM_PROMPT
system_parts.append(ctx.content if ctx and ctx.content.strip() else DEFAULT_SYSTEM_PROMPT)
# 2. Conversation history
# 2. Personal context
personal_ctx = await get_personal_context(db, user_id)
if personal_ctx and personal_ctx.content.strip():
system_parts.append(f"---\nUser Context:\n{personal_ctx.content}")
# 3. Active skill system prompt
chat = await get_chat(db, chat_id, user_id)
if chat.skill_id:
result = await db.execute(select(Skill).where(Skill.id == chat.skill_id))
skill = result.scalar_one_or_none()
if skill and skill.is_active:
system_parts.append(f"---\nSpecialist Role ({skill.name}):\n{skill.system_prompt}")
system_prompt = "\n\n".join(system_parts)
# 4. Conversation history
result = await db.execute(
select(Message)
.where(Message.chat_id == chat_id, Message.role.in_(["user", "assistant"]))
.order_by(Message.created_at.asc())
)
history = result.scalars().all()
messages = [{"role": msg.role, "content": msg.content} for msg in history]
# 3. Current user message
# 5. Current user message
messages.append({"role": "user", "content": user_message})
return system_prompt, messages
@@ -56,7 +73,7 @@ async def stream_ai_response(
try:
# Assemble context
system_prompt, messages = await assemble_context(db, chat_id, user_message)
system_prompt, messages = await assemble_context(db, chat_id, user_id, user_message)
# Stream from Claude
full_content = ""

View File

@@ -9,7 +9,7 @@ from app.models.message import Message
from app.models.user import User
async def create_chat(db: AsyncSession, user: User, title: str | None = None) -> Chat:
async def create_chat(db: AsyncSession, user: User, title: str | None = None, skill_id: uuid.UUID | None = None) -> Chat:
count = await db.scalar(
select(func.count()).select_from(Chat).where(
Chat.user_id == user.id, Chat.is_archived == False # noqa: E712
@@ -21,7 +21,7 @@ async def create_chat(db: AsyncSession, user: User, title: str | None = None) ->
detail="Chat limit reached. Archive or delete existing chats.",
)
chat = Chat(user_id=user.id, title=title or "New Chat")
chat = Chat(user_id=user.id, title=title or "New Chat", skill_id=skill_id)
db.add(chat)
await db.flush()
return chat
@@ -51,12 +51,15 @@ async def get_chat(db: AsyncSession, chat_id: uuid.UUID, user_id: uuid.UUID) ->
async def update_chat(
db: AsyncSession, chat_id: uuid.UUID, user_id: uuid.UUID,
title: str | None = None, is_archived: bool | None = None,
skill_id: uuid.UUID | None = None,
) -> Chat:
chat = await get_chat(db, chat_id, user_id)
if title is not None:
chat.title = title
if is_archived is not None:
chat.is_archived = is_archived
if skill_id is not None:
chat.skill_id = skill_id
await db.flush()
return chat

View File

@@ -23,6 +23,34 @@ async def get_primary_context(db: AsyncSession) -> ContextFile | None:
return result.scalar_one_or_none()
async def get_personal_context(db: AsyncSession, user_id: uuid.UUID) -> ContextFile | None:
result = await db.execute(
select(ContextFile).where(ContextFile.type == "personal", ContextFile.user_id == user_id)
)
return result.scalar_one_or_none()
async def upsert_personal_context(
db: AsyncSession, user_id: uuid.UUID, content: str
) -> ContextFile:
ctx = await get_personal_context(db, user_id)
if ctx:
ctx.content = content
ctx.version = ctx.version + 1
ctx.updated_by = user_id
else:
ctx = ContextFile(
type="personal",
user_id=user_id,
content=content,
version=1,
updated_by=user_id,
)
db.add(ctx)
await db.flush()
return ctx
async def upsert_primary_context(
db: AsyncSession, content: str, admin_user_id: uuid.UUID
) -> ContextFile:

View File

@@ -0,0 +1,97 @@
import uuid
from fastapi import HTTPException, status
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.skill import Skill
async def get_accessible_skills(
db: AsyncSession, user_id: uuid.UUID, include_general: bool = True
) -> list[Skill]:
conditions = [Skill.user_id == user_id]
if include_general:
conditions.append(Skill.user_id.is_(None))
stmt = select(Skill).where(or_(*conditions), Skill.is_active == True).order_by(Skill.sort_order) # noqa: E712
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_skill(db: AsyncSession, skill_id: uuid.UUID, user_id: uuid.UUID | None = None) -> Skill:
result = await db.execute(select(Skill).where(Skill.id == skill_id))
skill = result.scalar_one_or_none()
if not skill:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Skill not found")
# Access check: must be general or owned by user
if user_id and skill.user_id is not None and skill.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Skill not found")
return skill
async def validate_skill_accessible(db: AsyncSession, skill_id: uuid.UUID, user_id: uuid.UUID) -> None:
"""Validate skill exists and is accessible by user (general or owned). Raises 404 if not."""
await get_skill(db, skill_id, user_id)
# --- Personal skills ---
async def create_personal_skill(db: AsyncSession, user_id: uuid.UUID, **kwargs) -> Skill:
skill = Skill(user_id=user_id, **kwargs)
db.add(skill)
await db.flush()
return skill
async def update_personal_skill(db: AsyncSession, skill_id: uuid.UUID, user_id: uuid.UUID, **kwargs) -> Skill:
skill = await get_skill(db, skill_id, user_id)
if skill.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot edit general skills")
for key, value in kwargs.items():
if value is not None:
setattr(skill, key, value)
await db.flush()
return skill
async def delete_personal_skill(db: AsyncSession, skill_id: uuid.UUID, user_id: uuid.UUID) -> None:
skill = await get_skill(db, skill_id, user_id)
if skill.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete general skills")
await db.delete(skill)
# --- General (admin) skills ---
async def get_general_skills(db: AsyncSession) -> list[Skill]:
result = await db.execute(
select(Skill).where(Skill.user_id.is_(None)).order_by(Skill.sort_order)
)
return list(result.scalars().all())
async def create_general_skill(db: AsyncSession, **kwargs) -> Skill:
skill = Skill(user_id=None, **kwargs)
db.add(skill)
await db.flush()
return skill
async def update_general_skill(db: AsyncSession, skill_id: uuid.UUID, **kwargs) -> Skill:
result = await db.execute(select(Skill).where(Skill.id == skill_id, Skill.user_id.is_(None)))
skill = result.scalar_one_or_none()
if not skill:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="General skill not found")
for key, value in kwargs.items():
if value is not None:
setattr(skill, key, value)
await db.flush()
return skill
async def delete_general_skill(db: AsyncSession, skill_id: uuid.UUID) -> None:
result = await db.execute(select(Skill).where(Skill.id == skill_id, Skill.user_id.is_(None)))
skill = result.scalar_one_or_none()
if not skill:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="General skill not found")
await db.delete(skill)

View File

@@ -0,0 +1,142 @@
import pytest
from httpx import AsyncClient
@pytest.fixture
async def user_headers(client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"email": "skilluser@example.com",
"username": "skilluser",
"password": "testpass123",
})
assert resp.status_code == 201
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
@pytest.fixture
async def other_user_headers(client: AsyncClient):
resp = await client.post("/api/v1/auth/register", json={
"email": "skillother@example.com",
"username": "skillother",
"password": "testpass123",
})
assert resp.status_code == 201
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
# --- Personal Skills ---
async def test_create_personal_skill(client: AsyncClient, user_headers: dict):
resp = await client.post("/api/v1/skills/", json={
"name": "Nutritionist",
"description": "Diet and nutrition advice",
"system_prompt": "You are a nutritionist.",
}, headers=user_headers)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Nutritionist"
assert data["user_id"] is not None
async def test_list_personal_skills(client: AsyncClient, user_headers: dict):
await client.post("/api/v1/skills/", json={
"name": "Test Skill",
"system_prompt": "Test prompt",
}, headers=user_headers)
resp = await client.get("/api/v1/skills/", params={"include_general": False}, headers=user_headers)
assert resp.status_code == 200
assert len(resp.json()["skills"]) >= 1
async def test_update_personal_skill(client: AsyncClient, user_headers: dict):
resp = await client.post("/api/v1/skills/", json={
"name": "Old Name",
"system_prompt": "Prompt",
}, headers=user_headers)
skill_id = resp.json()["id"]
resp = await client.patch(f"/api/v1/skills/{skill_id}", json={
"name": "New Name",
}, headers=user_headers)
assert resp.status_code == 200
assert resp.json()["name"] == "New Name"
async def test_delete_personal_skill(client: AsyncClient, user_headers: dict):
resp = await client.post("/api/v1/skills/", json={
"name": "To Delete",
"system_prompt": "Prompt",
}, headers=user_headers)
skill_id = resp.json()["id"]
resp = await client.delete(f"/api/v1/skills/{skill_id}", headers=user_headers)
assert resp.status_code == 204
async def test_cannot_access_other_users_skill(client: AsyncClient, user_headers: dict, other_user_headers: dict):
resp = await client.post("/api/v1/skills/", json={
"name": "Private Skill",
"system_prompt": "Prompt",
}, headers=user_headers)
skill_id = resp.json()["id"]
# Other user can't see it
resp = await client.get(f"/api/v1/skills/{skill_id}", headers=other_user_headers)
assert resp.status_code == 404
# --- Admin Skills ---
async def test_non_admin_cannot_manage_general_skills(client: AsyncClient, user_headers: dict):
resp = await client.get("/api/v1/admin/skills", headers=user_headers)
assert resp.status_code == 403
resp = await client.post("/api/v1/admin/skills", json={
"name": "General",
"system_prompt": "Prompt",
}, headers=user_headers)
assert resp.status_code == 403
# --- Personal Context ---
async def test_personal_context_crud(client: AsyncClient, user_headers: dict):
# Initially null
resp = await client.get("/api/v1/users/me/context", headers=user_headers)
assert resp.status_code == 200
# Create
resp = await client.put("/api/v1/users/me/context", json={
"content": "I have diabetes type 2",
}, headers=user_headers)
assert resp.status_code == 200
data = resp.json()
assert data["content"] == "I have diabetes type 2"
assert data["version"] == 1
# Update
resp = await client.put("/api/v1/users/me/context", json={
"content": "I have diabetes type 2 and hypertension",
}, headers=user_headers)
assert resp.status_code == 200
assert resp.json()["version"] == 2
# --- Chat with Skill ---
async def test_create_chat_with_skill(client: AsyncClient, user_headers: dict):
# Create a skill first
resp = await client.post("/api/v1/skills/", json={
"name": "Cardiologist",
"system_prompt": "You are a cardiologist.",
}, headers=user_headers)
skill_id = resp.json()["id"]
# Create chat with skill
resp = await client.post("/api/v1/chats/", json={
"title": "Heart Consultation",
"skill_id": skill_id,
}, headers=user_headers)
assert resp.status_code == 201
assert resp.json()["skill_id"] == skill_id

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 /> },
],
},
],

View File

@@ -0,0 +1,109 @@
# Phase 3: Skills & Context — Subplan
## Goal
Deliver a skill system (general + personal) with CRUD, a personal context editor per user, and context assembly that layers personal context and skill prompts into AI conversations, with frontend management pages and a skill selector in chat.
## Prerequisites
- Phase 2 completed: chat CRUD, SSE streaming, Claude API integration, context assembly, admin context editor
- `context_files` table already exists (Phase 2 migration)
- `chats.skill_id` column already exists (nullable, Phase 2)
---
## Database Schema (Phase 3)
### `skills` table (new)
| Column | Type | Constraints |
|---|---|---|
| id | UUID | PK (inherited from Base) |
| user_id | UUID | FK -> users.id ON DELETE CASCADE, NULL = general skill |
| name | VARCHAR(100) | NOT NULL |
| description | TEXT | NULL |
| system_prompt | TEXT | NOT NULL |
| icon | VARCHAR(50) | NULL (Lucide icon name) |
| is_active | BOOLEAN | NOT NULL, default true |
| sort_order | INTEGER | NOT NULL, default 0 |
| created_at | TIMESTAMPTZ | inherited from Base |
Also: add FK constraint `chats.skill_id` -> `skills.id ON DELETE SET NULL`.
---
## Tasks
### A. Backend Model & Migration (Tasks 13)
- [x] **A1.** Create `backend/app/models/skill.py`. Add `skills` relationship on User model. Add `skill` relationship on Chat model.
- [x] **A2.** Update `backend/app/models/__init__.py`: import Skill.
- [x] **A3.** Create migration `003_create_skills_add_chat_skill_fk.py`.
### B. Backend Schemas (Task 4)
- [x] **B4.** Create `backend/app/schemas/skill.py`. Extend `CreateChatRequest`/`UpdateChatRequest` with optional `skill_id`.
### C. Backend Services (Tasks 57)
- [x] **C5.** Create `backend/app/services/skill_service.py`: CRUD for personal + general skills with access checks.
- [x] **C6.** Extend `context_service.py`: add `get_personal_context`, `upsert_personal_context`.
- [x] **C7.** Extend `ai_service.py` `assemble_context`: add personal context (step 2) + skill prompt (step 3).
### D. Backend API Endpoints (Tasks 811)
- [x] **D8.** Create `backend/app/api/v1/skills.py`: personal skills CRUD.
- [x] **D9.** Extend `backend/app/api/v1/admin.py`: admin skills CRUD under `/admin/skills/`.
- [x] **D10.** Create `backend/app/api/v1/users.py`: `GET/PUT /users/me/context`.
- [x] **D11.** Update router, chats endpoints (skill_id validation on create/update).
### E. Frontend API (Tasks 1213)
- [x] **E12.** Create `frontend/src/api/skills.ts` + extend `admin.ts` with admin skill functions.
- [x] **E13.** Create `frontend/src/api/user-context.ts`.
### F. Frontend Skill Selector (Tasks 1415)
- [x] **F14.** Create `frontend/src/components/chat/skill-selector.tsx`.
- [x] **F15.** Update chat creation + chat window to support skill_id.
### G. Frontend Admin Skills (Tasks 1617)
- [x] **G16.** Create `frontend/src/components/admin/skill-editor.tsx` (reusable form).
- [x] **G17.** Create `frontend/src/pages/admin/skills.tsx`.
### H. Frontend Personal Skills & Context (Tasks 1819)
- [x] **H18.** Create `frontend/src/pages/skills.tsx`.
- [x] **H19.** Create `frontend/src/pages/profile/context.tsx`.
### I. Routing, Sidebar, i18n (Tasks 2022)
- [x] **I20.** Update routes: `/skills`, `/profile/context`, `/admin/skills`.
- [x] **I21.** Update sidebar: add Skills nav, admin skills link.
- [x] **I22.** Update en/ru translations.
### J. Backend Tests (Task 23)
- [x] **J23.** Create `backend/tests/test_skills.py`.
---
## Acceptance Criteria
1. Migration creates `skills` table and adds FK on `chats.skill_id`
2. Admin can CRUD general skills; users cannot
3. Users can CRUD own personal skills; isolated from other users
4. Users can read/write personal context via `/users/me/context`
5. Context assembly: primary context + personal context + skill prompt
6. Chat creation/update accepts `skill_id`, validates accessibility
7. Skill selector dropdown in chat creation and header
8. Admin skills page, personal skills page, personal context editor
9. All UI text in English and Russian
10. Backend tests pass
---
## Status
**COMPLETED**