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:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
70
backend/app/api/v1/skills.py
Normal file
70
backend/app/api/v1/skills.py
Normal 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)
|
||||
33
backend/app/api/v1/users.py
Normal file
33
backend/app/api/v1/users.py
Normal 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)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
23
backend/app/models/skill.py
Normal file
23
backend/app/models/skill.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
40
backend/app/schemas/skill.py
Normal file
40
backend/app/schemas/skill.py
Normal 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]
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
97
backend/app/services/skill_service.py
Normal file
97
backend/app/services/skill_service.py
Normal 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)
|
||||
142
backend/tests/test_skills.py
Normal file
142
backend/tests/test_skills.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Произошла ошибка",
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
109
plans/phase-3-skills-context.md
Normal file
109
plans/phase-3-skills-context.md
Normal 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 1–3)
|
||||
|
||||
- [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 5–7)
|
||||
|
||||
- [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 8–11)
|
||||
|
||||
- [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 12–13)
|
||||
|
||||
- [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 14–15)
|
||||
|
||||
- [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 16–17)
|
||||
|
||||
- [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 18–19)
|
||||
|
||||
- [x] **H18.** Create `frontend/src/pages/skills.tsx`.
|
||||
- [x] **H19.** Create `frontend/src/pages/profile/context.tsx`.
|
||||
|
||||
### I. Routing, Sidebar, i18n (Tasks 20–22)
|
||||
|
||||
- [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**
|
||||
Reference in New Issue
Block a user