init
This commit is contained in:
@@ -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
@@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.db
|
||||||
|
.DS_Store
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
data/
|
||||||
@@ -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"]
|
||||||
@@ -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"])
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.chat.service import ChatService
|
||||||
|
|
||||||
|
__all__ = ["ChatService"]
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.llm.client import LLMClient
|
||||||
|
|
||||||
|
__all__ = ["LLMClient"]
|
||||||
@@ -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 {}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.pomodoro.service import PomodoroService
|
||||||
|
|
||||||
|
__all__ = ["PomodoroService"]
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
||||||
|
|
||||||
|
__all__ = ["TOOL_DEFINITIONS", "execute_tool"]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Ты домашний ИИ-ассистент. Общайся на русском языке.
|
||||||
|
|
||||||
|
Твои задачи:
|
||||||
|
- Отвечать на вопросы и давать практичные советы
|
||||||
|
- Помогать с помидоро-таймером: запускать, останавливать, проверять статус, смотреть историю
|
||||||
|
- Быть дружелюбным, кратким и по делу
|
||||||
|
|
||||||
|
Когда пользователь просит завести помидор, используй инструмент start_pomodoro.
|
||||||
|
Когда спрашивает статус таймера — get_pomodoro_status.
|
||||||
|
Когда хочет остановить — stop_pomodoro.
|
||||||
|
Когда спрашивает что делал — get_pomodoro_history.
|
||||||
|
|
||||||
|
Не выдумывай данные о таймере — всегда используй инструменты.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2943
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -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
@@ -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$
|
||||||
Reference in New Issue
Block a user