This commit is contained in:
2026-06-09 09:36:48 +03:00
parent 8247b7116f
commit f0fda693d8
49 changed files with 5503 additions and 1 deletions
+13
View File
@@ -0,0 +1,13 @@
# Server
PORT=8080
HOST=0.0.0.0
# OpenRouter
OPENROUTER_API_KEY=sk-or-v1-your-key-here
OPENROUTER_MODEL=deepseek/deepseek-chat
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# App
DATABASE_URL=sqlite:///./data/assistant.db
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3000
SYSTEM_PROMPT_PATH=./prompts/assistant.md
+10
View File
@@ -0,0 +1,10 @@
.env
data/
__pycache__/
*.pyc
.venv/
venv/
node_modules/
dist/
*.db
.DS_Store
+92 -1
View File
@@ -1,2 +1,93 @@
# Home_assistant # Home AI Assistant
Домашний ИИ-ассистент с REST API, веб-интерфейсом и помидоро-таймером. LLM — OpenRouter (по умолчанию DeepSeek).
## Возможности (MVP)
- Чат с потоковыми ответами (SSE)
- Управление помидоро из чата через tool calling
- REST API для внешних клиентов (Telegram-бот, мобильное приложение)
- Веб-морда: вкладки «Чат» и «Помидоро»
## Быстрый старт
### 1. Настройка окружения
```bash
cp .env.example .env
```
Заполните в `.env`:
```env
OPENROUTER_API_KEY=sk-or-v1-...
PORT=8080
```
### 2. Запуск через Docker
```bash
docker compose up --build
```
- Backend API: http://localhost:8080
- Web UI: http://localhost:3000
- Healthcheck: http://localhost:8080/api/v1/health
### 3. Локальная разработка
**Backend:**
```bash
cd backend
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8080
```
**Frontend:**
```bash
cd frontend
npm install
npm run dev
```
Vite dev-server: http://localhost:5173 (проксирует `/api` на backend).
## REST API
| Method | Path | Описание |
|--------|------|----------|
| GET | `/api/v1/health` | Healthcheck |
| POST | `/api/v1/chat/sessions` | Создать чат-сессию |
| GET | `/api/v1/chat/sessions` | Список сессий |
| GET | `/api/v1/chat/sessions/{id}` | История сообщений |
| POST | `/api/v1/chat/sessions/{id}/messages` | Отправить сообщение (SSE) |
| DELETE | `/api/v1/chat/sessions/{id}` | Удалить сессию |
| GET | `/api/v1/pomodoro/status` | Статус таймера |
| POST | `/api/v1/pomodoro/start` | Старт `{duration_min, task_note}` |
| POST | `/api/v1/pomodoro/pause` | Пауза |
| POST | `/api/v1/pomodoro/resume` | Продолжить |
| POST | `/api/v1/pomodoro/stop` | Стоп `{result, completed}` |
| GET | `/api/v1/pomodoro/history` | История сессий |
## Структура проекта
```
backend/ FastAPI, OpenRouter, SQLite, помидоро
frontend/ React + Vite, чат и таймер
data/ SQLite БД (создаётся автоматически)
```
## Следующие фазы
- Интеграция Taiga + Gitea (project-agent внутри проекта)
- RAG с Qdrant для документов
- Проактивные чаты по расписанию
- Фитнес-трекер
## Модель
По умолчанию: `deepseek/deepseek-chat` через OpenRouter. Альтернатива для болтовни: `google/gemini-2.0-flash`.
+4
View File
@@ -0,0 +1,4 @@
.venv
__pycache__
*.pyc
data/
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/data
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
View File
View File
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from app.api.routes import chat, health, pomodoro
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
+55
View File
@@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut
from app.chat.service import ChatService
from app.db.base import get_db
router = APIRouter()
@router.post("/sessions", response_model=SessionOut)
def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut:
service = ChatService(db)
return service.create_session(title=payload.title)
@router.get("/sessions", response_model=list[SessionOut])
def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]:
service = ChatService(db)
return service.list_sessions()
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut:
service = ChatService(db)
session = service.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
service = ChatService(db)
if not service.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@router.post("/sessions/{session_id}/messages")
async def send_message(
session_id: int,
payload: MessageCreate,
db: Session = Depends(get_db),
) -> StreamingResponse:
service = ChatService(db)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
async def event_stream():
async for chunk in service.stream_response(session_id, payload.content):
yield chunk
return StreamingResponse(event_stream(), media_type="text/event-stream")
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
+60
View File
@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.schemas import PomodoroStart, PomodoroStop
from app.db.base import get_db
from app.pomodoro.service import PomodoroService
router = APIRouter()
def _handle_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
@router.get("/status")
def get_status(db: Session = Depends(get_db)) -> dict:
return PomodoroService(db).get_status()
@router.post("/start")
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/pause")
def pause_pomodoro(db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).pause()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/resume")
def resume_pomodoro(db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).resume()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/stop")
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).stop(
result=payload.result,
completed=payload.completed,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]:
return PomodoroService(db).history(limit=limit)
+43
View File
@@ -0,0 +1,43 @@
from datetime import datetime
from pydantic import BaseModel, Field
class SessionCreate(BaseModel):
title: str = "Новый чат"
class SessionOut(BaseModel):
id: int
title: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class MessageOut(BaseModel):
id: int
role: str
content: str
created_at: datetime
model_config = {"from_attributes": True}
class SessionDetailOut(SessionOut):
messages: list[MessageOut]
class MessageCreate(BaseModel):
content: str = Field(min_length=1)
class PomodoroStart(BaseModel):
duration_min: int = Field(default=25, ge=1, le=180)
task_note: str = ""
class PomodoroStop(BaseModel):
result: str = ""
completed: bool = False
+3
View File
@@ -0,0 +1,3 @@
from app.chat.service import ChatService
__all__ = ["ChatService"]
+141
View File
@@ -0,0 +1,141 @@
import json
from collections.abc import AsyncIterator
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.models import ChatSession, Message
from app.llm.client import LLMClient
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
MAX_TOOL_ROUNDS = 5
class ChatService:
def __init__(self, db: Session):
self.db = db
self.llm = LLMClient()
self.system_prompt = get_settings().load_system_prompt()
def list_sessions(self) -> list[ChatSession]:
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
return list(self.db.scalars(stmt).all())
def get_session(self, session_id: int) -> ChatSession | None:
return self.db.get(ChatSession, session_id)
def create_session(self, title: str = "Новый чат") -> ChatSession:
session = ChatSession(title=title)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return session
def delete_session(self, session_id: int) -> bool:
session = self.get_session(session_id)
if not session:
return False
self.db.delete(session)
self.db.commit()
return True
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = [{"role": "system", "content": self.system_prompt}]
for msg in session.messages:
content = msg.content or None
entry: dict[str, Any] = {"role": msg.role, "content": content}
if msg.tool_calls_json:
entry["tool_calls"] = json.loads(msg.tool_calls_json)
if not content:
entry["content"] = None
if msg.role == "tool" and msg.tool_call_id:
entry["tool_call_id"] = msg.tool_call_id
messages.append(entry)
return messages
def _save_message(
self,
session_id: int,
role: str,
content: str = "",
tool_calls: list[dict[str, Any]] | None = None,
tool_call_id: str | None = None,
) -> Message:
message = Message(
session_id=session_id,
role=role,
content=content,
tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None,
tool_call_id=tool_call_id,
)
self.db.add(message)
session = self.get_session(session_id)
if session and role == "user" and session.title == "Новый чат" and content:
session.title = content[:60] + ("..." if len(content) > 60 else "")
self.db.commit()
self.db.refresh(message)
return message
async def stream_response(self, session_id: int, user_text: str) -> AsyncIterator[str]:
session = self.get_session(session_id)
if not session:
yield self._sse("error", {"message": "Session not found"})
return
self._save_message(session_id, "user", user_text)
messages = self._build_messages(session)
for _ in range(MAX_TOOL_ROUNDS):
content_parts: list[str] = []
tool_calls: list[dict[str, Any]] = []
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
if event["type"] == "content":
content_parts.append(event["content"])
yield self._sse("token", {"content": event["content"]})
elif event["type"] == "tool_calls":
tool_calls = event["tool_calls"]
if tool_calls:
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": "".join(content_parts) or None,
"tool_calls": tool_calls,
}
messages.append(assistant_msg)
self._save_message(
session_id,
"assistant",
"".join(content_parts),
tool_calls=tool_calls,
)
for tool_call in tool_calls:
fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
result = execute_tool(self.db, fn["name"], args)
tool_message = {
"role": "tool",
"tool_call_id": tool_call["id"],
"content": result,
}
messages.append(tool_message)
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
yield self._sse("tool", {"name": fn["name"], "result": json.loads(result)})
continue
final_content = "".join(content_parts)
if final_content:
self._save_message(session_id, "assistant", final_content)
yield self._sse("done", {})
return
yield self._sse("error", {"message": "Too many tool call rounds"})
@staticmethod
def _sse(event: str, data: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+38
View File
@@ -0,0 +1,38 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file_encoding="utf-8",
extra="ignore",
)
host: str = "0.0.0.0"
port: int = 8080
openrouter_api_key: str = ""
openrouter_model: str = "deepseek/deepseek-chat"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
database_url: str = "sqlite:///./data/assistant.db"
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
system_prompt_path: str = "./prompts/assistant.md"
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
def load_system_prompt(self) -> str:
path = Path(self.system_prompt_path)
if path.is_file():
return path.read_text(encoding="utf-8")
return "Ты домашний ИИ-ассистент. Общайся на русском."
@lru_cache
def get_settings() -> Settings:
return Settings()
+11
View File
@@ -0,0 +1,11 @@
from app.db.base import Base, get_db, init_db
from app.db.models import ChatSession, Message, PomodoroSession
__all__ = [
"Base",
"ChatSession",
"Message",
"PomodoroSession",
"get_db",
"init_db",
]
+39
View File
@@ -0,0 +1,39 @@
from collections.abc import Generator
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
class Base(DeclarativeBase):
pass
def _ensure_sqlite_dir(database_url: str) -> None:
if database_url.startswith("sqlite:///"):
db_path = database_url.replace("sqlite:///", "", 1)
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
settings = get_settings()
_ensure_sqlite_dir(settings.database_url)
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def init_db() -> None:
from app.db import models # noqa: F401
Base.metadata.create_all(bind=engine)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+51
View File
@@ -0,0 +1,51 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class ChatSession(Base):
__tablename__ = "chat_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
messages: Mapped[list["Message"]] = relationship(
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
)
class Message(Base):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
role: Mapped[str] = mapped_column(String(32))
content: Mapped[str] = mapped_column(Text, default="")
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
session: Mapped["ChatSession"] = relationship(back_populates="messages")
class PomodoroSession(Base):
__tablename__ = "pomodoro_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
status: Mapped[str] = mapped_column(String(32), default="idle")
duration_min: Mapped[int] = mapped_column(Integer, default=25)
task_note: Mapped[str] = mapped_column(Text, default="")
result: Mapped[str | None] = mapped_column(Text, nullable=True)
completed: Mapped[bool] = mapped_column(Boolean, default=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+3
View File
@@ -0,0 +1,3 @@
from app.llm.client import LLMClient
__all__ = ["LLMClient"]
+112
View File
@@ -0,0 +1,112 @@
import json
from collections.abc import AsyncIterator
from typing import Any
from openai import AsyncOpenAI
from app.config import get_settings
class LLMClient:
def __init__(self) -> None:
settings = get_settings()
self.model = settings.openrouter_model
self.client = AsyncOpenAI(
api_key=settings.openrouter_api_key,
base_url=settings.openrouter_base_url,
)
async def stream_chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> AsyncIterator[dict[str, Any]]:
kwargs: dict[str, Any] = {
"model": self.model,
"messages": messages,
"stream": True,
"temperature": 0.7,
}
if tools:
kwargs["tools"] = tools
stream = await self.client.chat.completions.create(**kwargs)
tool_calls: dict[int, dict[str, Any]] = {}
async for chunk in stream:
if not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
if delta.content:
yield {"type": "content", "content": delta.content}
if delta.tool_calls:
for tool_call in delta.tool_calls:
idx = tool_call.index
if idx not in tool_calls:
tool_calls[idx] = {
"id": tool_call.id or "",
"type": "function",
"function": {"name": "", "arguments": ""},
}
if tool_call.id:
tool_calls[idx]["id"] = tool_call.id
if tool_call.function:
if tool_call.function.name:
tool_calls[idx]["function"]["name"] = tool_call.function.name
if tool_call.function.arguments:
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
if choice.finish_reason:
if tool_calls:
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
yield {"type": "done", "finish_reason": choice.finish_reason}
async def complete(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": 0.7,
}
if tools:
kwargs["tools"] = tools
response = await self.client.chat.completions.create(**kwargs)
message = response.choices[0].message
result: dict[str, Any] = {
"content": message.content or "",
"tool_calls": [],
}
if message.tool_calls:
result["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in message.tool_calls
]
return result
@staticmethod
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
if not arguments:
return {}
try:
return json.loads(arguments)
except json.JSONDecodeError:
return {}
+33
View File
@@ -0,0 +1,33 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router
from app.config import get_settings
from app.db.base import init_db
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
yield
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title="Home AI Assistant", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
return app
app = create_app()
+3
View File
@@ -0,0 +1,3 @@
from app.pomodoro.service import PomodoroService
__all__ = ["PomodoroService"]
+152
View File
@@ -0,0 +1,152 @@
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import PomodoroSession
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class PomodoroService:
def __init__(self, db: Session):
self.db = db
def _get_active(self) -> PomodoroSession | None:
stmt = (
select(PomodoroSession)
.where(PomodoroSession.status.in_(("running", "paused")))
.order_by(PomodoroSession.id.desc())
.limit(1)
)
return self.db.scalar(stmt)
def _elapsed(self, session: PomodoroSession) -> int:
elapsed = session.elapsed_seconds
if session.status == "running" and session.started_at:
started = session.started_at
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
delta = _utcnow() - started
elapsed += int(delta.total_seconds())
return elapsed
def _remaining(self, session: PomodoroSession) -> int:
total = session.duration_min * 60
return max(0, total - self._elapsed(session))
def _to_status_dict(self, session: PomodoroSession | None) -> dict:
if not session:
return {
"status": "idle",
"duration_min": 25,
"task_note": "",
"elapsed_seconds": 0,
"remaining_seconds": 0,
"session_id": None,
}
elapsed = self._elapsed(session)
total = session.duration_min * 60
remaining = max(0, total - elapsed)
if session.status == "running" and remaining == 0:
session.status = "completed"
session.finished_at = _utcnow()
session.completed = True
self.db.commit()
self.db.refresh(session)
return {
"status": session.status,
"duration_min": session.duration_min,
"task_note": session.task_note,
"elapsed_seconds": elapsed,
"remaining_seconds": remaining,
"session_id": session.id,
"started_at": session.started_at.isoformat() if session.started_at else None,
"finished_at": session.finished_at.isoformat() if session.finished_at else None,
}
def get_status(self) -> dict:
return self._to_status_dict(self._get_active())
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
active = self._get_active()
if active:
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
session = PomodoroSession(
status="running",
duration_min=duration_min,
task_note=task_note,
started_at=_utcnow(),
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def pause(self) -> dict:
session = self._get_active()
if not session or session.status != "running":
raise ValueError("Нет активного запущенного таймера.")
session.elapsed_seconds = self._elapsed(session)
session.status = "paused"
session.paused_at = _utcnow()
session.started_at = None
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def resume(self) -> dict:
session = self._get_active()
if not session or session.status != "paused":
raise ValueError("Нет таймера на паузе.")
session.status = "running"
session.started_at = _utcnow()
session.paused_at = None
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def stop(self, result: str = "", completed: bool = False) -> dict:
session = self._get_active()
if not session:
raise ValueError("Нет активного таймера.")
session.elapsed_seconds = self._elapsed(session)
session.status = "completed" if completed else "cancelled"
session.result = result
session.completed = completed
session.finished_at = _utcnow()
session.started_at = None
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def history(self, limit: int = 20) -> list[dict]:
stmt = (
select(PomodoroSession)
.where(PomodoroSession.status.in_(("completed", "cancelled")))
.order_by(PomodoroSession.finished_at.desc())
.limit(limit)
)
sessions = self.db.scalars(stmt).all()
return [
{
"id": s.id,
"status": s.status,
"duration_min": s.duration_min,
"task_note": s.task_note,
"result": s.result,
"completed": s.completed,
"elapsed_seconds": s.elapsed_seconds,
"finished_at": s.finished_at.isoformat() if s.finished_at else None,
}
for s in sessions
]
+3
View File
@@ -0,0 +1,3 @@
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
__all__ = ["TOOL_DEFINITIONS", "execute_tool"]
+102
View File
@@ -0,0 +1,102 @@
import json
from typing import Any
from sqlalchemy.orm import Session
from app.pomodoro.service import PomodoroService
TOOL_DEFINITIONS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "get_pomodoro_status",
"description": "Получить текущий статус помидоро-таймера",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "start_pomodoro",
"description": "Запустить помидоро-таймер",
"parameters": {
"type": "object",
"properties": {
"duration_min": {
"type": "integer",
"description": "Длительность в минутах, по умолчанию 25",
},
"task_note": {
"type": "string",
"description": "Над чем работаем в этой сессии",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "stop_pomodoro",
"description": "Остановить текущий помидоро-таймер",
"parameters": {
"type": "object",
"properties": {
"result": {
"type": "string",
"description": "Краткий отчёт о том, что сделано",
},
"completed": {
"type": "boolean",
"description": "True если задача полностью завершена",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_pomodoro_history",
"description": "Получить историю завершённых помидоро-сессий",
"parameters": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Сколько последних сессий вернуть, по умолчанию 10",
}
},
"required": [],
},
},
},
]
def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str:
service = PomodoroService(db)
try:
if name == "get_pomodoro_status":
result = service.get_status()
elif name == "start_pomodoro":
result = service.start(
duration_min=arguments.get("duration_min", 25),
task_note=arguments.get("task_note", ""),
)
elif name == "stop_pomodoro":
result = service.stop(
result=arguments.get("result", ""),
completed=arguments.get("completed", False),
)
elif name == "get_pomodoro_history":
result = service.history(limit=arguments.get("limit", 10))
else:
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
return json.dumps(result, ensure_ascii=False)
except ValueError as exc:
return json.dumps({"error": str(exc)}, ensure_ascii=False)
+13
View File
@@ -0,0 +1,13 @@
Ты домашний ИИ-ассистент. Общайся на русском языке.
Твои задачи:
- Отвечать на вопросы и давать практичные советы
- Помогать с помидоро-таймером: запускать, останавливать, проверять статус, смотреть историю
- Быть дружелюбным, кратким и по делу
Когда пользователь просит завести помидор, используй инструмент start_pomodoro.
Когда спрашивает статус таймера — get_pomodoro_status.
Когда хочет остановить — stop_pomodoro.
Когда спрашивает что делал — get_pomodoro_history.
Не выдумывай данные о таймере — всегда используй инструменты.
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
sqlalchemy>=2.0.36
pydantic-settings>=2.6.0
openai>=1.55.0
python-dotenv>=1.0.1
aiosqlite>=0.20.0
+20
View File
@@ -0,0 +1,20 @@
services:
backend:
build: ./backend
ports:
- "${PORT:-8080}:8080"
env_file: .env
volumes:
- ./data:/app/data
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_API_URL: ""
ports:
- "3000:80"
depends_on:
- backend
restart: unless-stopped
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+19
View File
@@ -0,0 +1,19 @@
FROM node:20-alpine AS build
WORKDIR /app
ARG VITE_API_URL=
ENV VITE_API_URL=$VITE_API_URL
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Home AI Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+2943
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "home-assistant-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+41
View File
@@ -0,0 +1,41 @@
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #2a2f3a;
background: #151922;
}
.app-header h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.app-header nav {
display: flex;
gap: 0.75rem;
}
.app-header nav a {
padding: 0.45rem 0.9rem;
border-radius: 8px;
color: #a8b0bd;
}
.app-header nav a.active {
background: #2b3445;
color: #fff;
}
.app-main {
flex: 1;
min-height: 0;
}
+26
View File
@@ -0,0 +1,26 @@
import { NavLink, Route, Routes } from "react-router-dom";
import Chat from "./pages/Chat";
import Pomodoro from "./pages/Pomodoro";
import "./App.css";
export default function App() {
return (
<div className="app">
<header className="app-header">
<h1>Home AI Assistant</h1>
<nav>
<NavLink to="/" end>
Чат
</NavLink>
<NavLink to="/pomodoro">Помидоро</NavLink>
</nav>
</header>
<main className="app-main">
<Routes>
<Route path="/" element={<Chat />} />
<Route path="/pomodoro" element={<Pomodoro />} />
</Routes>
</main>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
const API_BASE = import.meta.env.VITE_API_URL ?? "";
export interface ChatSession {
id: number;
title: string;
created_at: string;
updated_at: string;
}
export interface ChatMessage {
id: number;
role: string;
content: string;
created_at: string;
}
export interface SessionDetail extends ChatSession {
messages: ChatMessage[];
}
export interface PomodoroStatus {
status: string;
duration_min: number;
task_note: string;
elapsed_seconds: number;
remaining_seconds: number;
session_id: number | null;
started_at?: string | null;
finished_at?: string | null;
}
export interface PomodoroHistoryItem {
id: number;
status: string;
duration_min: number;
task_note: string;
result: string | null;
completed: boolean;
elapsed_seconds: number;
finished_at: string | null;
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, options);
if (!response.ok) {
const text = await response.text();
throw new Error(text || response.statusText);
}
return response.json() as Promise<T>;
}
export const api = {
health: () => request<{ status: string }>("/api/v1/health"),
listSessions: () => request<ChatSession[]>("/api/v1/chat/sessions"),
createSession: (title = "Новый чат") =>
request<ChatSession>("/api/v1/chat/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
}),
getSession: (id: number) => request<SessionDetail>(`/api/v1/chat/sessions/${id}`),
deleteSession: (id: number) =>
request<{ ok: boolean }>(`/api/v1/chat/sessions/${id}`, { method: "DELETE" }),
sendMessage: async function* (sessionId: number, content: string) {
const response = await fetch(`${API_BASE}/api/v1/chat/sessions/${sessionId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!response.ok || !response.body) {
throw new Error("Failed to send message");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? "";
for (const part of parts) {
const lines = part.split("\n");
let event = "message";
let data = "";
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
if (line.startsWith("data: ")) data = line.slice(6);
}
if (data) {
yield { event, data: JSON.parse(data) };
}
}
}
},
pomodoroStatus: () => request<PomodoroStatus>("/api/v1/pomodoro/status"),
pomodoroStart: (duration_min: number, task_note: string) =>
request<PomodoroStatus>("/api/v1/pomodoro/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ duration_min, task_note }),
}),
pomodoroPause: () =>
request<PomodoroStatus>("/api/v1/pomodoro/pause", { method: "POST" }),
pomodoroResume: () =>
request<PomodoroStatus>("/api/v1/pomodoro/resume", { method: "POST" }),
pomodoroStop: (result: string, completed: boolean) =>
request<PomodoroStatus>("/api/v1/pomodoro/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result, completed }),
}),
pomodoroHistory: () => request<PomodoroHistoryItem[]>("/api/v1/pomodoro/history"),
};
+32
View File
@@ -0,0 +1,32 @@
:root {
color-scheme: dark;
font-family: Inter, system-ui, -apple-system, sans-serif;
line-height: 1.5;
font-weight: 400;
color: #e8eaed;
background-color: #0f1115;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
a {
color: inherit;
text-decoration: none;
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+156
View File
@@ -0,0 +1,156 @@
.chat-layout {
display: grid;
grid-template-columns: 280px 1fr;
height: calc(100vh - 65px);
}
.chat-sidebar {
border-right: 1px solid #2a2f3a;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
background: #12151c;
}
.primary-btn {
background: #4f7cff;
color: white;
border: none;
border-radius: 8px;
padding: 0.65rem 1rem;
}
.session-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.session-list li {
display: flex;
align-items: center;
gap: 0.25rem;
border-radius: 8px;
}
.session-list li.active {
background: #1f2633;
}
.session-list li button:first-child {
flex: 1;
text-align: left;
background: transparent;
border: none;
color: inherit;
padding: 0.55rem 0.7rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.delete-btn {
background: transparent;
border: none;
color: #7d8796;
padding: 0.3rem 0.5rem;
}
.chat-main {
display: flex;
flex-direction: column;
min-height: 0;
}
.chat-empty {
margin: auto;
color: #7d8796;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.message {
max-width: 80%;
padding: 0.9rem 1rem;
border-radius: 12px;
}
.message-user {
align-self: flex-end;
background: #2b4acb;
}
.message-assistant,
.message-tool {
align-self: flex-start;
background: #1b2130;
border: 1px solid #2a3142;
}
.message-role {
font-size: 0.75rem;
color: #8b95a5;
margin-bottom: 0.35rem;
text-transform: uppercase;
}
.message-content p {
margin: 0 0 0.5rem;
}
.message-content p:last-child {
margin-bottom: 0;
}
.chat-input {
display: flex;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #2a2f3a;
}
.chat-input textarea {
flex: 1;
resize: none;
border-radius: 10px;
border: 1px solid #2f3748;
background: #12151c;
color: inherit;
padding: 0.75rem 1rem;
}
.chat-input button {
align-self: flex-end;
background: #4f7cff;
color: white;
border: none;
border-radius: 8px;
padding: 0.65rem 1.2rem;
}
.chat-input button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) {
.chat-layout {
grid-template-columns: 1fr;
}
.chat-sidebar {
display: none;
}
}
+167
View File
@@ -0,0 +1,167 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { api, ChatMessage, ChatSession } from "../api/client";
import "./Chat.css";
export default function Chat() {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeId, setActiveId] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
const loadSessions = async () => {
const data = await api.listSessions();
setSessions(data);
if (!activeId && data.length > 0) {
setActiveId(data[0].id);
}
};
const loadMessages = async (sessionId: number) => {
const data = await api.getSession(sessionId);
setMessages(data.messages);
};
useEffect(() => {
loadSessions().catch(console.error);
}, []);
useEffect(() => {
if (activeId) {
loadMessages(activeId).catch(console.error);
}
}, [activeId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streaming]);
const handleNewChat = async () => {
const session = await api.createSession();
await loadSessions();
setActiveId(session.id);
setMessages([]);
};
const handleDelete = async (id: number) => {
await api.deleteSession(id);
const data = await api.listSessions();
setSessions(data);
if (activeId === id) {
setActiveId(data[0]?.id ?? null);
setMessages([]);
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!input.trim() || !activeId || loading) return;
const text = input.trim();
setInput("");
setLoading(true);
setStreaming("");
const tempUser: ChatMessage = {
id: Date.now(),
role: "user",
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, tempUser]);
try {
for await (const chunk of api.sendMessage(activeId, text)) {
if (chunk.event === "token") {
setStreaming((prev) => prev + chunk.data.content);
}
if (chunk.event === "done") {
await loadMessages(activeId);
await loadSessions();
setStreaming("");
}
if (chunk.event === "error") {
throw new Error(chunk.data.message);
}
}
} catch (err) {
console.error(err);
setStreaming("");
} finally {
setLoading(false);
}
};
return (
<div className="chat-layout">
<aside className="chat-sidebar">
<button className="primary-btn" onClick={handleNewChat}>
+ Новый чат
</button>
<ul className="session-list">
{sessions.map((session) => (
<li key={session.id} className={activeId === session.id ? "active" : ""}>
<button onClick={() => setActiveId(session.id)}>{session.title}</button>
<button className="delete-btn" onClick={() => handleDelete(session.id)}>
×
</button>
</li>
))}
</ul>
</aside>
<section className="chat-main">
{!activeId ? (
<div className="chat-empty">Создайте новый чат, чтобы начать</div>
) : (
<>
<div className="messages">
{messages.map((msg) => (
<div key={msg.id} className={`message message-${msg.role}`}>
<div className="message-role">{msg.role}</div>
<div className="message-content">
{msg.role === "assistant" ? (
<ReactMarkdown>{msg.content}</ReactMarkdown>
) : (
msg.content
)}
</div>
</div>
))}
{streaming && (
<div className="message message-assistant">
<div className="message-role">assistant</div>
<div className="message-content">
<ReactMarkdown>{streaming}</ReactMarkdown>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
<form className="chat-input" onSubmit={handleSubmit}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение..."
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
/>
<button type="submit" disabled={loading || !input.trim()}>
{loading ? "..." : "Отправить"}
</button>
</form>
</>
)}
</section>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
.pomodoro-page {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
padding: 1.5rem;
max-width: 1100px;
margin: 0 auto;
}
.timer-card,
.history-card {
background: #151922;
border: 1px solid #2a2f3a;
border-radius: 16px;
padding: 1.5rem;
}
.timer-ring {
width: 220px;
height: 220px;
border-radius: 50%;
margin: 0 auto 1.5rem;
display: grid;
place-items: center;
}
.timer-inner {
width: 180px;
height: 180px;
border-radius: 50%;
background: #0f1115;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.timer-value {
font-size: 2.4rem;
font-weight: 700;
}
.timer-status {
color: #8b95a5;
text-transform: uppercase;
font-size: 0.8rem;
}
.task-note {
text-align: center;
color: #c5ccd6;
}
.timer-form,
.timer-controls,
.stop-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.timer-form label,
.stop-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
color: #a8b0bd;
}
.timer-form input,
.stop-form input {
border-radius: 8px;
border: 1px solid #2f3748;
background: #0f1115;
color: inherit;
padding: 0.6rem 0.8rem;
}
.timer-controls button,
.primary-btn {
background: #4f7cff;
color: white;
border: none;
border-radius: 8px;
padding: 0.65rem 1rem;
}
.error {
color: #ff7b7b;
margin-top: 0.75rem;
}
.history-card h2 {
margin-top: 0;
}
.history-card ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.history-card li {
padding-bottom: 0.9rem;
border-bottom: 1px solid #232936;
}
.history-title {
font-weight: 600;
}
.history-meta {
color: #8b95a5;
font-size: 0.85rem;
margin-top: 0.2rem;
}
.history-result {
margin-top: 0.35rem;
color: #c5ccd6;
}
@media (max-width: 900px) {
.pomodoro-page {
grid-template-columns: 1fr;
}
}
+167
View File
@@ -0,0 +1,167 @@
import { FormEvent, useEffect, useState } from "react";
import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client";
import "./Pomodoro.css";
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
export default function Pomodoro() {
const [status, setStatus] = useState<PomodoroStatus | null>(null);
const [history, setHistory] = useState<PomodoroHistoryItem[]>([]);
const [duration, setDuration] = useState(25);
const [taskNote, setTaskNote] = useState("");
const [result, setResult] = useState("");
const [completed, setCompleted] = useState(false);
const [error, setError] = useState("");
const refresh = async () => {
const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]);
setStatus(current);
setHistory(past);
};
useEffect(() => {
refresh().catch(console.error);
const timer = setInterval(() => {
api.pomodoroStatus().then(setStatus).catch(console.error);
}, 1000);
return () => clearInterval(timer);
}, []);
const handleStart = async (e: FormEvent) => {
e.preventDefault();
setError("");
try {
const data = await api.pomodoroStart(duration, taskNote);
setStatus(data);
setResult("");
setCompleted(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка запуска");
}
};
const handlePause = async () => {
setError("");
try {
setStatus(await api.pomodoroPause());
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
}
};
const handleResume = async () => {
setError("");
try {
setStatus(await api.pomodoroResume());
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
}
};
const handleStop = async () => {
setError("");
try {
await api.pomodoroStop(result, completed);
await refresh();
setTaskNote("");
setResult("");
setCompleted(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
}
};
const isActive = status?.status === "running" || status?.status === "paused";
const displaySeconds = isActive ? (status?.remaining_seconds ?? 0) : duration * 60;
const progress = status
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
: 0;
return (
<div className="pomodoro-page">
<section className="timer-card">
<div className="timer-ring" style={{ background: `conic-gradient(#4f7cff ${progress}%, #1f2633 0)` }}>
<div className="timer-inner">
<div className="timer-value">{formatTime(displaySeconds)}</div>
<div className="timer-status">{status?.status ?? "idle"}</div>
</div>
</div>
{status?.task_note && <p className="task-note">Задача: {status.task_note}</p>}
{!isActive ? (
<form className="timer-form" onSubmit={handleStart}>
<label>
Минут
<input
type="number"
min={1}
max={180}
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
/>
</label>
<label>
Над чем работаем
<input
value={taskNote}
onChange={(e) => setTaskNote(e.target.value)}
placeholder="Опишите задачу"
/>
</label>
<button type="submit" className="primary-btn">
Старт
</button>
</form>
) : (
<div className="timer-controls">
{status?.status === "running" ? (
<button onClick={handlePause}>Пауза</button>
) : (
<button onClick={handleResume}>Продолжить</button>
)}
<div className="stop-form">
<input
value={result}
onChange={(e) => setResult(e.target.value)}
placeholder="Что успели сделать?"
/>
<label>
<input
type="checkbox"
checked={completed}
onChange={(e) => setCompleted(e.target.checked)}
/>
Задача завершена
</label>
<button onClick={handleStop}>Стоп</button>
</div>
</div>
)}
{error && <p className="error">{error}</p>}
</section>
<section className="history-card">
<h2>История</h2>
<ul>
{history.map((item) => (
<li key={item.id}>
<div className="history-title">
{item.task_note || "Без описания"} {item.status}
</div>
<div className="history-meta">
{item.duration_min} мин · {item.finished_at ? new Date(item.finished_at).toLocaleString("ru-RU") : ""}
</div>
{item.result && <div className="history-result">{item.result}</div>}
</li>
))}
</ul>
</section>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/pages/chat.tsx","./src/pages/pomodoro.tsx"],"version":"5.9.3"}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
});
+525
View File
@@ -0,0 +1,525 @@
grigo@grigosserver:~/to_services$ ls
3x ai-tools funkwhale jenkins kokoro minecraft netdata openclaw-data qbtorrent spotify.cred taiga xtts
LoraTester aiChatBot gitea jitsi lidarr navidrome octo-fiesta openclaw-gateway server spotizerr taiga-manual-broken
acore-docker fishtts jellyfin juice metube neko open-meteo project-agent spotdown stable-10888.zip voice-server
grigo@grigosserver:~/to_services$ ls ai-tools/
taiga-tasker
grigo@grigosserver:~/to_services$ ls ai-tools/taiga-tasker/
create_taiga_task.py .env .venv/
grigo@grigosserver:~/to_services$ ls ai-tools/taiga-tasker/create_taiga_task.py
ai-tools/taiga-tasker/create_taiga_task.py
grigo@grigosserver:~/to_services$ cat ai-tools/taiga-tasker/create_taiga_task.py
#!/usr/bin/env python3
import json
import os
import re
import sys
from typing import Any
from pathlib import Path
import requests
from dotenv import load_dotenv
SCRIPT_DIR = Path(__file__).resolve().parent
load_dotenv(SCRIPT_DIR / ".env")
TAIGA_BASE_URL = os.getenv("TAIGA_BASE_URL", "http://127.0.0.1:9000").rstrip("/")
TAIGA_USERNAME = os.getenv("TAIGA_USERNAME")
TAIGA_PASSWORD = os.getenv("TAIGA_PASSWORD")
TAIGA_DEFAULT_PROJECT_ID = os.getenv("TAIGA_DEFAULT_PROJECT_ID")
AIMTR_BASE_URL = os.getenv("AIMTR_BASE_URL", "https://aimtr.wellflow.dev/v1").rstrip("/")
AIMTR_API_KEY = os.getenv("AIMTR_API_KEY")
AIMTR_MODEL = os.getenv("AIMTR_MODEL", "claude-haiku-4.5")
def require_env() -> None:
missing = []
for key, value in {
"TAIGA_USERNAME": TAIGA_USERNAME,
"TAIGA_PASSWORD": TAIGA_PASSWORD,
"TAIGA_DEFAULT_PROJECT_ID": TAIGA_DEFAULT_PROJECT_ID,
"AIMTR_API_KEY": AIMTR_API_KEY,
}.items():
if not value:
missing.append(key)
if missing:
raise SystemExit(f"Missing env vars in .env: {', '.join(missing)}")
def strip_markdown_json(text: str) -> str:
text = text.strip()
fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL | re.IGNORECASE)
if fenced:
return fenced.group(1).strip()
return text
def aimtr_make_task(raw_text: str) -> dict[str, Any]:
system_prompt = """
Ты технический ассистент для Taiga.
Преобразуй сырое описание задачи в строгий JSON.
Отвечай только JSON, без markdown и пояснений.
Схема:
{
"title": "короткое название задачи",
"description": "понятное описание задачи",
"type": "Story",
"priority": "low|normal|high",
"tags": ["tag1", "tag2"],
"acceptance_criteria": [
"критерий приемки 1",
"критерий приемки 2"
],
"children": [
{
"title": "название подзадачи",
"description": "описание подзадачи",
"type": "Task",
"priority": "low|normal|high"
}
],
"questions": [
"уточняющий вопрос, если данных не хватает"
]
}
Правила:
- Пиши на русском.
- Не придумывай бизнес-ограничения, которых нет в запросе.
- Если данных мало, добавь вопросы в questions.
- Acceptance criteria должны быть проверяемыми.
- children должны быть техническими подзадачами.
""".strip()
response = requests.post(
f"{AIMTR_BASE_URL}/chat/completions",
headers={
"Authorization": f"Bearer {AIMTR_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": AIMTR_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": raw_text},
],
"temperature": 0.2,
},
timeout=60,
)
response.raise_for_status()
content = response.json()["choices"][0]["message"]["content"]
clean = strip_markdown_json(content)
try:
return json.loads(clean)
except json.JSONDecodeError as exc:
print("LLM returned invalid JSON:")
print(content)
raise SystemExit(exc)
def taiga_auth() -> str:
response = requests.post(
f"{TAIGA_BASE_URL}/api/v1/auth",
json={
"type": "normal",
"username": TAIGA_USERNAME,
"password": TAIGA_PASSWORD,
},
timeout=15,
)
response.raise_for_status()
return response.json()["auth_token"]
def taiga_headers(token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def format_story_description(task: dict[str, Any], raw_text: str) -> str:
lines = []
lines.append(task.get("description") or raw_text)
acceptance = task.get("acceptance_criteria") or []
if acceptance:
lines.append("")
lines.append("## Acceptance criteria")
for item in acceptance:
lines.append(f"- {item}")
questions = task.get("questions") or []
if questions:
lines.append("")
lines.append("## Вопросы / уточнения")
for item in questions:
lines.append(f"- {item}")
tags = task.get("tags") or []
if tags:
lines.append("")
lines.append("## Теги")
lines.append(", ".join(tags))
lines.append("")
lines.append("## Исходное описание")
lines.append(raw_text)
return "\n".join(lines).strip()
def create_userstory(token: str, project_id: int, task: dict[str, Any], raw_text: str) -> dict[str, Any]:
subject = (task.get("title") or raw_text).strip()[:500]
description = format_story_description(task, raw_text)
response = requests.post(
f"{TAIGA_BASE_URL}/api/v1/userstories",
headers=taiga_headers(token),
json={
"project": project_id,
"subject": subject,
"description": description,
},
timeout=15,
)
response.raise_for_status()
return response.json()
def create_subtask(token: str, project_id: int, userstory_id: int, child: dict[str, Any]) -> dict[str, Any]:
subject = (child.get("title") or "Подзадача").strip()[:500]
description = (child.get("description") or "").strip()
response = requests.post(
f"{TAIGA_BASE_URL}/api/v1/tasks",
headers=taiga_headers(token),
json={
"project": project_id,
"user_story": userstory_id,
"subject": subject,
"description": description,
},
timeout=15,
)
response.raise_for_status()
return response.json()
def main() -> None:
require_env()
if len(sys.argv) < 2:
raise SystemExit('Usage: ./create_taiga_task.py "Описание задачи" [PROJECT_ID]')
raw_text = sys.argv[1].strip()
project_id = int(sys.argv[2]) if len(sys.argv) > 2 else int(TAIGA_DEFAULT_PROJECT_ID)
print("Generating structured task with AIMTR...")
structured = aimtr_make_task(raw_text)
print("Authenticating in Taiga...")
token = taiga_auth()
print("Creating user story...")
story = create_userstory(token, project_id, structured, raw_text)
created_tasks = []
for child in structured.get("children", []):
if isinstance(child, dict):
created_tasks.append(create_subtask(token, project_id, story["id"], child))
print("")
print(f"Created user story: #{story['ref']} / id={story['id']}")
print(f"Subject: {story['subject']}")
print(f"Subtasks created: {len(created_tasks)}")
for task in created_tasks:
print(f"- #{task.get('ref')} {task.get('subject')}")
if __name__ == "__main__":
main()
grigo@grigosserver:~/to_services$ ls open
openclaw-data/ openclaw-gateway/ open-meteo/
grigo@grigosserver:~/to_services$ ls openclaw-data/
auth/ config/ workspace/
grigo@grigosserver:~/to_services$ ls openclaw-data/workspace/
advanced_integration.c final-checklist.sh main_full.c PINOUT_WIRING.md SOUL.md STEPPER_WIRING.md
AGENTS.md fix-acore-docker.sh memory/ PROJECT_SUMMARY.sh START_HERE.md test_stepper.c
auto-setup.sh fix-docker-compose.sh MEMORY.md QUICK_REFERENCE.md stepper_advanced.c TOOLS.md
catalog_images.py .git/ motor_advanced.py quick-start.sh stepper_examples.py TUNING_GUIDE.md
CMakeLists.txt HEARTBEAT.md motor_integration.py README_COMPLETE.md stepper_motor.c USER.md
C_SDK_GUIDE.md IDENTITY.md motor_web_server.py rk3566-yc-p6602.dts stepper_motor_control.py verify-installation.sh
diagnostics.c install-dependencies.sh .openclaw/ setup_stepper_project.sh stepper_motor.h viewer.html
FILES_MANIFEST.md main_basic.c organize_stepper_files.sh skills/ STEPPER_QUICK_START.md viewer_server.py
grigo@grigosserver:~/to_services$ ls openclaw-data/workspace/skills/project_agent/SKILL.md
openclaw-data/workspace/skills/project_agent/SKILL.md
grigo@grigosserver:~/to_services$ cat openclaw-data/workspace/skills/project_agent/SKILL.md
---
name: project_agent
description: Manage project-agent projects through API, add/register/update/list/sync projects, create Taiga tasks from natural language, analyze repository code, and choose the next Pomodoro action. Use this instead of MEMORY.md when the user asks to add, register, update, sync, list, or show projects.
---
# Project Agent
Use this skill when the user asks to:
- add, register, update, list, show, or sync projects in project-agent;
- create a development task in Taiga;
- formalize a feature request, bug, fix, or idea;
- decompose work into subtasks;
- analyze repository code before creating a task;
- choose what to work on next;
- pick the best task for a Pomodoro;
- report progress after a Pomodoro.
The project-agent service is available at:
http://project-agent:8787
## Available projects
Project list is dynamic. To see current projects, call:
curl -sS http://project-agent:8787/projects
Known projects may include:
- AISHub
- AIsMas-Web-Service
- AndroidAisMap
- PrivateTest
- Testing
- ClawSetUp
Default project: AISHub.
## Manage project-agent projects
Use this section when the user asks to:
- add a project;
- register a project;
- create a project in project-agent;
- update a project;
- sync a project;
- list projects;
- show project config.
Important:
- Do not write project definitions to MEMORY.md.
- Do not store project definitions in notes, memory, markdown files, or local summaries.
- Project definitions must be stored in project-agent via API.
- Do not say a project was added unless the POST /projects API returned "ok": true.
- If the user asks to add a project without sync, do not call /sync.
- Only call /projects/PROJECT_NAME/sync if the user explicitly says the Gitea repository already exists or asks to sync.
- If required fields are missing, ask only for the missing fields.
Required fields for adding a project:
- project name;
- kind: home or work;
- Taiga project id;
- Gitea repo in OWNER/REPO format;
- default branch. Use main if not specified.
To list projects, call:
curl -sS http://project-agent:8787/projects
To show one project, call:
curl -sS http://project-agent:8787/projects/PROJECT_NAME
To add a project, call exactly:
curl -sS -X POST http://project-agent:8787/projects \
-H "Content-Type: application/json" \
-d '{"name":"PROJECT_NAME","kind":"home","ai_tags":["home"],"taiga_project_id":9,"taiga_slug":"PROJECT_SLUG","gitea_repo":"OWNER/REPO","repo_url":"ssh://git@host.docker.internal:222/OWNER/REPO.git","repo_path":"/repos/PROJECT_NAME","default_branch":"main"}'
To update a project, call exactly:
curl -sS -X PUT http://project-agent:8787/projects/PROJECT_NAME \
-H "Content-Type: application/json" \
-d '{"name":"PROJECT_NAME","kind":"home","ai_tags":["home"],"taiga_project_id":9,"taiga_slug":"PROJECT_SLUG","gitea_repo":"OWNER/REPO","repo_url":"ssh://git@host.docker.internal:222/OWNER/REPO.git","repo_path":"/repos/PROJECT_NAME","default_branch":"main"}'
To delete a project, call only when the user explicitly asks to delete or remove it:
curl -sS -X DELETE http://project-agent:8787/projects/PROJECT_NAME
To sync a project only when requested, call:
curl -sS -X POST http://project-agent:8787/projects/PROJECT_NAME/sync
After adding or updating a project:
- call GET /projects/PROJECT_NAME;
- show the returned config;
- mention whether sync was skipped or executed;
- if sync was skipped, say that the project is registered but the repository was not cloned.
Expected response format for project add or update:
Проект сохранён в project-agent:
- Name: PROJECT_NAME
- Kind: home/work
- Taiga ID: ID
- Gitea repo: OWNER/REPO
- Repo path: /repos/PROJECT_NAME
- Default branch: main
- Sync: skipped/executed
Do not mention MEMORY.md.
## Create code-aware Taiga task
When the user asks to create, formalize, decompose, or register a Taiga task with repository/code context, call exactly this command:
curl -sS -X POST http://project-agent:8787/tasks/from-code \
-H "Content-Type: application/json" \
-d '{"project":"PROJECT_NAME","text":"USER_TASK_TEXT"}'
Use this for phrases like:
- "создай задачу";
- "оформи хотелку";
- "заведи bug";
- "разложи на подзадачи";
- "создай задачу с учетом кода";
- "вот список ошибок и хотелок";
- "добавь это в Taiga".
Replace:
- PROJECT_NAME with one of the available projects. Use AISHub if the project is not specified.
- USER_TASK_TEXT with the user's full task description.
After the call, always include these fields from the JSON response:
- Taiga story: story.ref, story.id, story.subject
- Gitea issue: gitea_issue.url
- Suggested branch: gitea_issue.branch
- Labels: gitea_issue.labels
- Created subtasks: subtasks[].ref and subtasks[].subject
- Useful code notes: structured.code_notes
- Blocking questions only: structured.questions
Do not omit Gitea issue, branch, or labels if they exist in the JSON response.
Mandatory response format for /tasks/from-code:
Создано:
- Taiga: #{{story.ref}} — {{story.subject}}
- Gitea: {{gitea_issue.url}}
- Рекомендуемая ветка: {{gitea_issue.branch}}
- Labels: {{gitea_issue.labels}}
Подзадачи:
{{for each item in subtasks}}
- #{{ref}} — {{subject}}
Заметки по коду:
{{for each item in structured.code_notes}}
- {{item}}
Вопросы/блокеры:
{{only include structured.questions that block implementation}}
Rules:
- Use the exact URL from gitea_issue.url.
- Use the exact branch from gitea_issue.branch.
- Use the exact labels from gitea_issue.labels.
- Do not say "branch created" unless the JSON explicitly contains "branch_created": true.
- Do not claim a subtask count unless you count the subtasks array exactly.
- Do not rewrite the response into a generic project-management summary.
## Pick next Pomodoro action
When the user asks what to work on next, what is the current priority, or what task to do during a Pomodoro, call exactly this command:
curl -sS -X POST http://project-agent:8787/next-action \
-H "Content-Type: application/json" \
-d '{"project":"PROJECT_NAME","minutes":25,"energy":"normal","notes":"USER_CONTEXT"}'
Use this for phrases like:
- "Я завожу помидор, что делать?";
- "Что сейчас в приоритете?";
- "Какую задачу взять на 25 минут?";
- "Что делать дальше по AISHub?";
- "Выбери следующую задачу";
- "Помоги выбрать задачу на сейчас".
Replace:
- PROJECT_NAME with one of the available projects. Use AISHub if the project is not specified.
- minutes with the user's Pomodoro duration. Use 25 if not specified.
- energy with low, normal, or high. Use normal if not specified.
- USER_CONTEXT with the user's current context, constraints, and notes.
Important:
- Return exactly ONE recommended task.
- Start the answer with the task ref and title.
- Do not offer alternative tasks unless the selected task is blocked.
- Do not say "or you can do X".
- Do not ask "what do you choose?" after recommending a Pomodoro task.
- Give a concrete execution plan for the requested timebox.
- Give a clear definition of done.
- Mention open questions only if they block the selected task.
- Use only POST /next-action.
- Do not call /tasks.
- Do not call /next-task.
After the call, format the response like this:
Бери #REF — "TITLE".
Почему сейчас:
...
План на 25 минут:
1. ...
2. ...
3. ...
Definition of done:
- ...
- ...
Блокер/вопрос:
...
If there is no blocker, omit the blocker section.
## Finish Pomodoro / report progress
When the user reports what they did after a Pomodoro, call exactly this command:
curl -sS -X POST http://project-agent:8787/pomodoro/finish \
-H "Content-Type: application/json" \
-d '{"project":"PROJECT_NAME","task_ref":TASK_REF,"minutes":25,"result":"USER_REPORT","done":false}'
Use this for phrases like:
- "я закончил помидор";
- "отчет по задаче";
- "сделал X, не успел Y";
- "закрой задачу";
- "готово";
- "задача #17 сделана".
Rules:
- If the user says the task is finished, set done=true.
- If the user only reports partial progress, set done=false.
- If task ref is missing, ask for the task number.
- After the call, summarize what was recorded in Taiga.
- Then return the next recommended Pomodoro action from the response.
## Safety
- Use only the project-agent API.
- Do not access Taiga, Gitea, Jenkins, or repositories directly.
- Do not ask the user for Taiga URL, token, username, or password.
- Do not print secrets, .env contents, tokens, passwords, SSH keys, or Authorization headers.
- Do not delete, push, merge, deploy, or modify repositories.
- Do not run arbitrary shell commands unrelated to project-agent.
- Do not write project definitions to MEMORY.md.
grigo@grigosserver:~/to_services$