From 244935e4ac3313dfb8c9ed892536f95b10bb81f4 Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 9 Jun 2026 11:26:28 +0300 Subject: [PATCH] fixed --- backend/app/api/routes/__init__.py | 3 +- backend/app/api/routes/character.py | 56 +++++++ backend/app/character/__init__.py | 3 + backend/app/character/card.py | 70 ++++++++ backend/app/character/service.py | 27 +++ backend/app/chat/notices.py | 75 +++++++++ backend/app/chat/service.py | 31 +++- backend/app/tools/registry.py | 6 +- frontend/src/App.css | 1 + frontend/src/App.tsx | 5 + frontend/src/api/client.ts | 31 ++++ frontend/src/components/PomodoroWidget.css | 74 ++++++++ frontend/src/components/PomodoroWidget.tsx | 39 +++++ frontend/src/hooks/usePomodoro.ts | 27 +++ frontend/src/pages/Character.css | 73 ++++++++ frontend/src/pages/Character.tsx | 186 +++++++++++++++++++++ frontend/src/pages/Chat.css | 12 +- frontend/src/pages/Chat.tsx | 51 +++++- frontend/src/utils/characterCard.ts | 124 ++++++++++++++ frontend/src/utils/time.ts | 5 + frontend/tsconfig.tsbuildinfo | 2 +- 21 files changed, 886 insertions(+), 15 deletions(-) create mode 100644 backend/app/api/routes/character.py create mode 100644 backend/app/character/__init__.py create mode 100644 backend/app/character/card.py create mode 100644 backend/app/character/service.py create mode 100644 backend/app/chat/notices.py create mode 100644 frontend/src/components/PomodoroWidget.css create mode 100644 frontend/src/components/PomodoroWidget.tsx create mode 100644 frontend/src/hooks/usePomodoro.ts create mode 100644 frontend/src/pages/Character.css create mode 100644 frontend/src/pages/Character.tsx create mode 100644 frontend/src/utils/characterCard.ts create mode 100644 frontend/src/utils/time.ts diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 4118880..6c90ead 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.routes import chat, health, pomodoro +from app.api.routes import character, 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"]) +api_router.include_router(character.router, tags=["character"]) diff --git a/backend/app/api/routes/character.py b/backend/app/api/routes/character.py new file mode 100644 index 0000000..d20d2b9 --- /dev/null +++ b/backend/app/api/routes/character.py @@ -0,0 +1,56 @@ +from typing import Any + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.character.service import CharacterService + +router = APIRouter() + + +class CharacterCardData(BaseModel): + name: str = "Ассистент" + description: str = "" + personality: str = "" + scenario: str = "" + first_mes: str = "" + mes_example: str = "" + system_prompt: str = "" + post_history_instructions: str = "" + tags: list[str] = Field(default_factory=list) + creator: str = "" + creator_notes: str = "" + alternate_greetings: list[str] = Field(default_factory=list) + character_version: str = "1.0" + + +class CharacterCardV2(BaseModel): + spec: str = "chara_card_v2" + spec_version: str = "2.0" + data: CharacterCardData + + +@router.get("/character") +def get_character() -> dict[str, Any]: + return CharacterService().get_card() + + +@router.put("/character") +def update_character(payload: CharacterCardV2) -> dict[str, Any]: + return CharacterService().save_card(payload.model_dump()) + + +@router.get("/character/prompt") +def get_character_prompt() -> dict[str, str]: + service = CharacterService() + return { + "system_prompt": service.get_system_prompt(), + "first_mes": service.get_card().get("data", {}).get("first_mes", ""), + } + + +@router.post("/character/import") +def import_character(payload: dict[str, Any]) -> dict[str, Any]: + if not payload: + raise HTTPException(status_code=400, detail="Empty card") + return CharacterService().save_card(payload) diff --git a/backend/app/character/__init__.py b/backend/app/character/__init__.py new file mode 100644 index 0000000..d095e15 --- /dev/null +++ b/backend/app/character/__init__.py @@ -0,0 +1,3 @@ +from app.character.service import CharacterService + +__all__ = ["CharacterService"] diff --git a/backend/app/character/card.py b/backend/app/character/card.py new file mode 100644 index 0000000..dbddb85 --- /dev/null +++ b/backend/app/character/card.py @@ -0,0 +1,70 @@ +from typing import Any + +TOOLS_INSTRUCTIONS = """ +Ты также домашний ассистент с инструментами. Обязательные правила: +- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент. +- Никогда не выдумывай статус таймера или список задач. +- После вызова инструмента кратко объясни результат пользователю по-человечески. +- Инструменты: get_pomodoro_status, start_pomodoro, stop_pomodoro, get_pomodoro_history. +""".strip() + +DEFAULT_CARD: dict[str, Any] = { + "spec": "chara_card_v2", + "spec_version": "2.0", + "data": { + "name": "Домашний ассистент", + "description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.", + "personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.", + "scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.", + "first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?", + "mes_example": "", + "system_prompt": "", + "post_history_instructions": "", + "alternate_greetings": [], + "tags": ["assistant", "home", "pomodoro"], + "creator": "", + "creator_notes": "", + "character_version": "1.0", + }, +} + + +def normalize_card(raw: dict[str, Any]) -> dict[str, Any]: + if "data" in raw and isinstance(raw["data"], dict): + card = { + "spec": raw.get("spec", "chara_card_v2"), + "spec_version": raw.get("spec_version", "2.0"), + "data": {**DEFAULT_CARD["data"], **raw["data"]}, + } + return card + + if "name" in raw or "description" in raw: + return { + "spec": "chara_card_v2", + "spec_version": "2.0", + "data": {**DEFAULT_CARD["data"], **raw}, + } + + return DEFAULT_CARD.copy() + + +def build_system_prompt(card: dict[str, Any]) -> str: + data = card.get("data", {}) + parts: list[str] = [] + + name = data.get("name", "Ассистент") + parts.append(f"Ты — {name}.") + + if data.get("system_prompt"): + parts.append(data["system_prompt"]) + if data.get("description"): + parts.append(data["description"]) + if data.get("personality"): + parts.append(f"Характер: {data['personality']}") + if data.get("scenario"): + parts.append(f"Сценарий: {data['scenario']}") + if data.get("post_history_instructions"): + parts.append(data["post_history_instructions"]) + + parts.append(TOOLS_INSTRUCTIONS) + return "\n\n".join(part for part in parts if part.strip()) diff --git a/backend/app/character/service.py b/backend/app/character/service.py new file mode 100644 index 0000000..37fe26e --- /dev/null +++ b/backend/app/character/service.py @@ -0,0 +1,27 @@ +import json +from pathlib import Path +from typing import Any + +from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card + +CARD_PATH = Path("./data/character.json") + + +class CharacterService: + def get_card(self) -> dict[str, Any]: + if CARD_PATH.is_file(): + try: + raw = json.loads(CARD_PATH.read_text(encoding="utf-8")) + return normalize_card(raw) + except (json.JSONDecodeError, OSError): + pass + return normalize_card(DEFAULT_CARD) + + def save_card(self, raw: dict[str, Any]) -> dict[str, Any]: + card = normalize_card(raw) + CARD_PATH.parent.mkdir(parents=True, exist_ok=True) + CARD_PATH.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8") + return card + + def get_system_prompt(self) -> str: + return build_system_prompt(self.get_card()) diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py new file mode 100644 index 0000000..93ab5c9 --- /dev/null +++ b/backend/app/chat/notices.py @@ -0,0 +1,75 @@ +import json +from typing import Any + + +def _format_time(seconds: int) -> str: + minutes, secs = divmod(max(0, seconds), 60) + return f"{minutes:02d}:{secs:02d}" + + +def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None: + try: + data = json.loads(raw_result) + except json.JSONDecodeError: + return None + + if isinstance(data, dict) and "error" in data: + return f"⏱ Помидоро: {data['error']}" + + if tool_name in ("get_pomodoro_status", "start_pomodoro", "stop_pomodoro"): + return _format_status_notice(data) + + if tool_name == "get_pomodoro_history": + return _format_history_notice(data) + + return None + + +def _format_status_notice(data: dict[str, Any]) -> str: + status = data.get("status", "idle") + task = data.get("task_note") or "без описания" + remaining = data.get("remaining_seconds", 0) + duration = data.get("duration_min", 25) + + if status == "idle": + return "⏱ **Помидоро:** таймер не запущен." + + if status == "running": + return ( + f"⏱ **Помидоро запущен** · осталось **{_format_time(remaining)}** " + f"из {duration} мин · задача: _{task}_" + ) + + if status == "paused": + elapsed = data.get("elapsed_seconds", 0) + return ( + f"⏱ **Помидоро на паузе** · прошло {_format_time(elapsed)} " + f"из {duration} мин · задача: _{task}_" + ) + + if status == "completed": + return f"⏱ **Помидоро завершён** · {duration} мин · задача: _{task}_" + + if status == "cancelled": + return f"⏱ **Помидоро отменён** · задача: _{task}_" + + return f"⏱ Помидоро: {status}" + + +def _format_history_notice(data: Any) -> str: + if not isinstance(data, list) or not data: + return "⏱ **История помидоро** пуста." + + lines = ["⏱ **История помидоро:**"] + for item in data[:10]: + task = item.get("task_note") or "без описания" + status = item.get("status", "?") + duration = item.get("duration_min", "?") + lines.append(f"- {task} ({duration} мин, {status})") + + return "\n".join(lines) + + +def format_pomodoro_context(status: dict[str, Any]) -> str: + notice = _format_status_notice(status) + return f"[Актуальный статус помидоро]\n{notice}" diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index 298ec91..19bcc59 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -5,9 +5,11 @@ from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session -from app.config import get_settings +from app.character.service import CharacterService +from app.chat.notices import format_pomodoro_context, format_pomodoro_notice from app.db.models import ChatSession, Message from app.llm.client import LLMClient +from app.pomodoro.service import PomodoroService from app.tools.registry import TOOL_DEFINITIONS, execute_tool MAX_TOOL_ROUNDS = 5 @@ -17,7 +19,7 @@ class ChatService: def __init__(self, db: Session): self.db = db self.llm = LLMClient() - self.system_prompt = get_settings().load_system_prompt() + self.character = CharacterService() def list_sessions(self) -> list[ChatSession]: stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()) @@ -41,9 +43,21 @@ class ChatService: self.db.commit() return True + def _build_system_prompt(self) -> str: + status = PomodoroService(self.db).get_status() + return ( + f"{self.character.get_system_prompt()}\n\n" + f"{format_pomodoro_context(status)}" + ) + def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]: - messages: list[dict[str, Any]] = [{"role": "system", "content": self.system_prompt}] + messages: list[dict[str, Any]] = [ + {"role": "system", "content": self._build_system_prompt()} + ] for msg in session.messages: + if msg.role == "notice": + continue + content = msg.content or None entry: dict[str, Any] = {"role": msg.role, "content": content} if msg.tool_calls_json: @@ -123,7 +137,16 @@ class ChatService: } 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)}) + + notice = format_pomodoro_notice(fn["name"], result) + if notice: + self._save_message(session_id, "notice", notice) + yield self._sse("notice", {"content": notice}) + + yield self._sse( + "pomodoro", + {"name": fn["name"], "result": json.loads(result)}, + ) continue diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 95c6958..8b097a4 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -10,7 +10,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "function", "function": { "name": "get_pomodoro_status", - "description": "Получить текущий статус помидоро-таймера", + "description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Возвращает актуальный статус помидоро.", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, @@ -18,7 +18,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "function", "function": { "name": "start_pomodoro", - "description": "Запустить помидоро-таймер", + "description": "Запустить помидоро-таймер. Вызывай при каждой просьбе поставить таймер — не полагайся на память.", "parameters": { "type": "object", "properties": { @@ -60,7 +60,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "function", "function": { "name": "get_pomodoro_history", - "description": "Получить историю завершённых помидоро-сессий", + "description": "ОБЯЗАТЕЛЬНО вызывай при вопросах о задачах, истории работы или что пользователь делал.", "parameters": { "type": "object", "properties": { diff --git a/frontend/src/App.css b/frontend/src/App.css index 398dbbc..57487f4 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -21,6 +21,7 @@ .app-header nav { display: flex; + align-items: center; gap: 0.75rem; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 852ee06..d42d957 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,6 @@ import { NavLink, Route, Routes } from "react-router-dom"; +import PomodoroWidget from "./components/PomodoroWidget"; +import Character from "./pages/Character"; import Chat from "./pages/Chat"; import Pomodoro from "./pages/Pomodoro"; import "./App.css"; @@ -13,12 +15,15 @@ export default function App() { Чат Помидоро + Персонаж +
} /> } /> + } />
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e55fcdf..e0ec59b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -29,6 +29,28 @@ export interface PomodoroStatus { finished_at?: string | null; } +export interface CharacterCardData { + name: string; + description: string; + personality: string; + scenario: string; + first_mes: string; + mes_example: string; + system_prompt: string; + post_history_instructions: string; + tags: string[]; + creator: string; + creator_notes: string; + alternate_greetings: string[]; + character_version: string; +} + +export interface CharacterCardV2 { + spec: string; + spec_version: string; + data: CharacterCardData; +} + export interface PomodoroHistoryItem { id: number; status: string; @@ -129,4 +151,13 @@ export const api = { }), pomodoroHistory: () => request("/api/v1/pomodoro/history"), + + getCharacter: () => request("/api/v1/character"), + + saveCharacter: (card: CharacterCardV2) => + request("/api/v1/character", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(card), + }), }; diff --git a/frontend/src/components/PomodoroWidget.css b/frontend/src/components/PomodoroWidget.css new file mode 100644 index 0000000..e1fc545 --- /dev/null +++ b/frontend/src/components/PomodoroWidget.css @@ -0,0 +1,74 @@ +.pomodoro-widget { + display: block; + padding: 0.75rem; + border-radius: 12px; + background: #1b2130; + border: 1px solid #2a3142; + color: inherit; + text-decoration: none; + transition: border-color 0.15s; +} + +.pomodoro-widget:hover { + border-color: #4f7cff; +} + +.pomodoro-widget-ring { + width: 88px; + height: 88px; + border-radius: 50%; + margin: 0 auto; + display: grid; + place-items: center; +} + +.pomodoro-widget.compact .pomodoro-widget-ring { + width: 44px; + height: 44px; +} + +.pomodoro-widget-inner { + width: 72px; + height: 72px; + border-radius: 50%; + background: #12151c; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.pomodoro-widget.compact .pomodoro-widget-inner { + width: 36px; + height: 36px; +} + +.pomodoro-widget-time { + font-size: 0.95rem; + font-weight: 700; + line-height: 1.1; +} + +.pomodoro-widget.compact .pomodoro-widget-time { + font-size: 0.55rem; +} + +.pomodoro-widget-label { + font-size: 0.6rem; + color: #8b95a5; + text-transform: uppercase; +} + +.pomodoro-widget.compact .pomodoro-widget-label { + display: none; +} + +.pomodoro-widget-task { + margin: 0.5rem 0 0; + font-size: 0.8rem; + color: #a8b0bd; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/frontend/src/components/PomodoroWidget.tsx b/frontend/src/components/PomodoroWidget.tsx new file mode 100644 index 0000000..de4773b --- /dev/null +++ b/frontend/src/components/PomodoroWidget.tsx @@ -0,0 +1,39 @@ +import { Link } from "react-router-dom"; +import { usePomodoro } from "../hooks/usePomodoro"; +import { formatTime } from "../utils/time"; +import "./PomodoroWidget.css"; + +interface PomodoroWidgetProps { + compact?: boolean; +} + +export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps) { + const { status } = usePomodoro(); + + if (!status) return null; + + const isActive = status.status === "running" || status.status === "paused"; + const displaySeconds = isActive ? status.remaining_seconds : status.duration_min * 60; + const progress = isActive + ? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100 + : 0; + + return ( + +
+
+ {formatTime(displaySeconds)} + + {status.status === "idle" ? "помидоро" : status.status} + +
+
+ {!compact && status.task_note && ( +

{status.task_note}

+ )} + + ); +} diff --git a/frontend/src/hooks/usePomodoro.ts b/frontend/src/hooks/usePomodoro.ts new file mode 100644 index 0000000..4902e7f --- /dev/null +++ b/frontend/src/hooks/usePomodoro.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useState } from "react"; +import { api, PomodoroStatus } from "../api/client"; + +export function usePomodoro(pollMs = 1000) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + const data = await api.pomodoroStatus(); + setStatus(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка загрузки таймера"); + } + }, []); + + useEffect(() => { + refresh().catch(console.error); + const timer = setInterval(() => { + refresh().catch(console.error); + }, pollMs); + return () => clearInterval(timer); + }, [refresh, pollMs]); + + return { status, error, refresh }; +} diff --git a/frontend/src/pages/Character.css b/frontend/src/pages/Character.css new file mode 100644 index 0000000..ea54255 --- /dev/null +++ b/frontend/src/pages/Character.css @@ -0,0 +1,73 @@ +.character-page { + max-width: 800px; + margin: 0 auto; + padding: 1.5rem; +} + +.character-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.character-header h2 { + margin: 0 0 0.35rem; +} + +.character-header p { + margin: 0; + color: #8b95a5; + font-size: 0.9rem; +} + +.character-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.character-actions button { + background: #2b3445; + color: inherit; + border: 1px solid #3a4558; + border-radius: 8px; + padding: 0.5rem 0.85rem; +} + +.character-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.character-form label { + display: flex; + flex-direction: column; + gap: 0.35rem; + color: #a8b0bd; + font-size: 0.9rem; +} + +.character-form input, +.character-form textarea { + border-radius: 8px; + border: 1px solid #2f3748; + background: #12151c; + color: inherit; + padding: 0.65rem 0.8rem; + font-family: inherit; +} + +.character-footer { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 0.5rem; +} + +.character-message { + color: #8b95a5; + font-size: 0.9rem; +} diff --git a/frontend/src/pages/Character.tsx b/frontend/src/pages/Character.tsx new file mode 100644 index 0000000..e42be1a --- /dev/null +++ b/frontend/src/pages/Character.tsx @@ -0,0 +1,186 @@ +import { FormEvent, useEffect, useRef, useState } from "react"; +import { api } from "../api/client"; +import { + CharacterCardV2, + DEFAULT_CARD, + exportCardJson, + normalizeCard, + parseCharacterFile, +} from "../utils/characterCard"; +import "./Character.css"; + +export default function Character() { + const [card, setCard] = useState(DEFAULT_CARD); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(""); + const fileRef = useRef(null); + + useEffect(() => { + api + .getCharacter() + .then((data) => setCard(normalizeCard(data))) + .catch(console.error); + }, []); + + const updateField = ( + key: K, + value: CharacterCardV2["data"][K] + ) => { + setCard((prev) => ({ + ...prev, + data: { ...prev.data, [key]: value }, + })); + }; + + const handleSave = async (e: FormEvent) => { + e.preventDefault(); + setSaving(true); + setMessage(""); + try { + const saved = await api.saveCharacter(card); + setCard(normalizeCard(saved)); + setMessage("Сохранено"); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка сохранения"); + } finally { + setSaving(false); + } + }; + + const handleImport = async (file: File) => { + try { + const imported = await parseCharacterFile(file); + setCard(imported); + setMessage(`Импортировано: ${imported.data.name}`); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка импорта"); + } + }; + + return ( +
+
+
+

Редактор персонажа

+

Формат chara_card_v2 — совместим с Chub AI / SillyTavern

+
+
+ + + { + const file = e.target.files?.[0]; + if (file) handleImport(file); + e.target.value = ""; + }} + /> +
+
+ +
+ + +