fixed
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
from fastapi import APIRouter
|
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 = APIRouter(prefix="/api/v1")
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||||
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
||||||
|
api_router.include_router(character.router, tags=["character"])
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.character.service import CharacterService
|
||||||
|
|
||||||
|
__all__ = ["CharacterService"]
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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}"
|
||||||
@@ -5,9 +5,11 @@ from typing import Any
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
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.db.models import ChatSession, Message
|
||||||
from app.llm.client import LLMClient
|
from app.llm.client import LLMClient
|
||||||
|
from app.pomodoro.service import PomodoroService
|
||||||
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
||||||
|
|
||||||
MAX_TOOL_ROUNDS = 5
|
MAX_TOOL_ROUNDS = 5
|
||||||
@@ -17,7 +19,7 @@ class ChatService:
|
|||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.llm = LLMClient()
|
self.llm = LLMClient()
|
||||||
self.system_prompt = get_settings().load_system_prompt()
|
self.character = CharacterService()
|
||||||
|
|
||||||
def list_sessions(self) -> list[ChatSession]:
|
def list_sessions(self) -> list[ChatSession]:
|
||||||
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
|
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
|
||||||
@@ -41,9 +43,21 @@ class ChatService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return True
|
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]]:
|
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:
|
for msg in session.messages:
|
||||||
|
if msg.role == "notice":
|
||||||
|
continue
|
||||||
|
|
||||||
content = msg.content or None
|
content = msg.content or None
|
||||||
entry: dict[str, Any] = {"role": msg.role, "content": content}
|
entry: dict[str, Any] = {"role": msg.role, "content": content}
|
||||||
if msg.tool_calls_json:
|
if msg.tool_calls_json:
|
||||||
@@ -123,7 +137,16 @@ class ChatService:
|
|||||||
}
|
}
|
||||||
messages.append(tool_message)
|
messages.append(tool_message)
|
||||||
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
|
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
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "get_pomodoro_status",
|
"name": "get_pomodoro_status",
|
||||||
"description": "Получить текущий статус помидоро-таймера",
|
"description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Возвращает актуальный статус помидоро.",
|
||||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -18,7 +18,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "start_pomodoro",
|
"name": "start_pomodoro",
|
||||||
"description": "Запустить помидоро-таймер",
|
"description": "Запустить помидоро-таймер. Вызывай при каждой просьбе поставить таймер — не полагайся на память.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -60,7 +60,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "get_pomodoro_history",
|
"name": "get_pomodoro_history",
|
||||||
"description": "Получить историю завершённых помидоро-сессий",
|
"description": "ОБЯЗАТЕЛЬНО вызывай при вопросах о задачах, истории работы или что пользователь делал.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
.app-header nav {
|
.app-header nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { NavLink, Route, Routes } from "react-router-dom";
|
import { NavLink, Route, Routes } from "react-router-dom";
|
||||||
|
import PomodoroWidget from "./components/PomodoroWidget";
|
||||||
|
import Character from "./pages/Character";
|
||||||
import Chat from "./pages/Chat";
|
import Chat from "./pages/Chat";
|
||||||
import Pomodoro from "./pages/Pomodoro";
|
import Pomodoro from "./pages/Pomodoro";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
@@ -13,12 +15,15 @@ export default function App() {
|
|||||||
Чат
|
Чат
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/pomodoro">Помидоро</NavLink>
|
<NavLink to="/pomodoro">Помидоро</NavLink>
|
||||||
|
<NavLink to="/character">Персонаж</NavLink>
|
||||||
|
<PomodoroWidget compact />
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Chat />} />
|
<Route path="/" element={<Chat />} />
|
||||||
<Route path="/pomodoro" element={<Pomodoro />} />
|
<Route path="/pomodoro" element={<Pomodoro />} />
|
||||||
|
<Route path="/character" element={<Character />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,28 @@ export interface PomodoroStatus {
|
|||||||
finished_at?: string | null;
|
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 {
|
export interface PomodoroHistoryItem {
|
||||||
id: number;
|
id: number;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -129,4 +151,13 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
pomodoroHistory: () => request<PomodoroHistoryItem[]>("/api/v1/pomodoro/history"),
|
pomodoroHistory: () => request<PomodoroHistoryItem[]>("/api/v1/pomodoro/history"),
|
||||||
|
|
||||||
|
getCharacter: () => request<CharacterCardV2>("/api/v1/character"),
|
||||||
|
|
||||||
|
saveCharacter: (card: CharacterCardV2) =>
|
||||||
|
request<CharacterCardV2>("/api/v1/character", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(card),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Link to="/pomodoro" className={`pomodoro-widget ${compact ? "compact" : ""}`}>
|
||||||
|
<div
|
||||||
|
className="pomodoro-widget-ring"
|
||||||
|
style={{ background: `conic-gradient(#4f7cff ${progress}%, #1f2633 0)` }}
|
||||||
|
>
|
||||||
|
<div className="pomodoro-widget-inner">
|
||||||
|
<span className="pomodoro-widget-time">{formatTime(displaySeconds)}</span>
|
||||||
|
<span className="pomodoro-widget-label">
|
||||||
|
{status.status === "idle" ? "помидоро" : status.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!compact && status.task_note && (
|
||||||
|
<p className="pomodoro-widget-task">{status.task_note}</p>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<PomodoroStatus | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<CharacterCardV2>(DEFAULT_CARD);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.getCharacter()
|
||||||
|
.then((data) => setCard(normalizeCard(data)))
|
||||||
|
.catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateField = <K extends keyof CharacterCardV2["data"]>(
|
||||||
|
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 (
|
||||||
|
<div className="character-page">
|
||||||
|
<header className="character-header">
|
||||||
|
<div>
|
||||||
|
<h2>Редактор персонажа</h2>
|
||||||
|
<p>Формат chara_card_v2 — совместим с Chub AI / SillyTavern</p>
|
||||||
|
</div>
|
||||||
|
<div className="character-actions">
|
||||||
|
<button type="button" onClick={() => fileRef.current?.click()}>
|
||||||
|
Импорт .json / .png
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => exportCardJson(card)}>
|
||||||
|
Экспорт .json
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,.png,image/png"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleImport(file);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="character-form" onSubmit={handleSave}>
|
||||||
|
<label>
|
||||||
|
Имя
|
||||||
|
<input
|
||||||
|
value={card.data.name}
|
||||||
|
onChange={(e) => updateField("name", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Описание
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={card.data.description}
|
||||||
|
onChange={(e) => updateField("description", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Характер (personality)
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={card.data.personality}
|
||||||
|
onChange={(e) => updateField("personality", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Сценарий (scenario)
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={card.data.scenario}
|
||||||
|
onChange={(e) => updateField("scenario", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
System prompt (опционально)
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={card.data.system_prompt}
|
||||||
|
onChange={(e) => updateField("system_prompt", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Первое сообщение (first_mes)
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={card.data.first_mes}
|
||||||
|
onChange={(e) => updateField("first_mes", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Примеры диалога (mes_example)
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={card.data.mes_example}
|
||||||
|
onChange={(e) => updateField("mes_example", e.target.value)}
|
||||||
|
placeholder="<START> {{user}}: Привет {{char}}: Привет!"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Post-history instructions
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={card.data.post_history_instructions}
|
||||||
|
onChange={(e) => updateField("post_history_instructions", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Теги (через запятую)
|
||||||
|
<input
|
||||||
|
value={card.data.tags.join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(
|
||||||
|
"tags",
|
||||||
|
e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="character-footer">
|
||||||
|
<button type="submit" className="primary-btn" disabled={saving}>
|
||||||
|
{saving ? "Сохранение..." : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
{message && <span className="character-message">{message}</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
background: #12151c;
|
background: #12151c;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-btn {
|
.primary-btn {
|
||||||
@@ -92,13 +93,20 @@
|
|||||||
background: #2b4acb;
|
background: #2b4acb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-assistant,
|
.message-assistant {
|
||||||
.message-tool {
|
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
background: #1b2130;
|
background: #1b2130;
|
||||||
border: 1px solid #2a3142;
|
border: 1px solid #2a3142;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-notice {
|
||||||
|
align-self: center;
|
||||||
|
max-width: 90%;
|
||||||
|
background: #1a2a1f;
|
||||||
|
border: 1px solid #2d5a3d;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
.message-role {
|
.message-role {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #8b95a5;
|
color: #8b95a5;
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { api, ChatMessage, ChatSession } from "../api/client";
|
import { api, ChatMessage, ChatSession } from "../api/client";
|
||||||
|
import PomodoroWidget from "../components/PomodoroWidget";
|
||||||
|
import { usePomodoro } from "../hooks/usePomodoro";
|
||||||
import "./Chat.css";
|
import "./Chat.css";
|
||||||
|
|
||||||
|
function shouldShowMessage(msg: ChatMessage): boolean {
|
||||||
|
if (msg.role === "tool") return false;
|
||||||
|
if (msg.role === "assistant" && !msg.content.trim()) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role: string): string {
|
||||||
|
if (role === "notice") return "таймер";
|
||||||
|
if (role === "user") return "вы";
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Chat() {
|
export default function Chat() {
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
const [activeId, setActiveId] = useState<number | null>(null);
|
const [activeId, setActiveId] = useState<number | null>(null);
|
||||||
@@ -10,7 +24,9 @@ export default function Chat() {
|
|||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [streaming, setStreaming] = useState("");
|
const [streaming, setStreaming] = useState("");
|
||||||
|
const [liveNotices, setLiveNotices] = useState<string[]>([]);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { refresh: refreshPomodoro } = usePomodoro();
|
||||||
|
|
||||||
const loadSessions = async () => {
|
const loadSessions = async () => {
|
||||||
const data = await api.listSessions();
|
const data = await api.listSessions();
|
||||||
@@ -37,13 +53,14 @@ export default function Chat() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages, streaming]);
|
}, [messages, streaming, liveNotices]);
|
||||||
|
|
||||||
const handleNewChat = async () => {
|
const handleNewChat = async () => {
|
||||||
const session = await api.createSession();
|
const session = await api.createSession();
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
setActiveId(session.id);
|
setActiveId(session.id);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setLiveNotices([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
@@ -53,6 +70,7 @@ export default function Chat() {
|
|||||||
if (activeId === id) {
|
if (activeId === id) {
|
||||||
setActiveId(data[0]?.id ?? null);
|
setActiveId(data[0]?.id ?? null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setLiveNotices([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,6 +82,7 @@ export default function Chat() {
|
|||||||
setInput("");
|
setInput("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
|
setLiveNotices([]);
|
||||||
|
|
||||||
const tempUser: ChatMessage = {
|
const tempUser: ChatMessage = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
@@ -78,10 +97,18 @@ export default function Chat() {
|
|||||||
if (chunk.event === "token") {
|
if (chunk.event === "token") {
|
||||||
setStreaming((prev) => prev + chunk.data.content);
|
setStreaming((prev) => prev + chunk.data.content);
|
||||||
}
|
}
|
||||||
|
if (chunk.event === "notice") {
|
||||||
|
setLiveNotices((prev) => [...prev, chunk.data.content]);
|
||||||
|
refreshPomodoro();
|
||||||
|
}
|
||||||
|
if (chunk.event === "pomodoro") {
|
||||||
|
refreshPomodoro();
|
||||||
|
}
|
||||||
if (chunk.event === "done") {
|
if (chunk.event === "done") {
|
||||||
await loadMessages(activeId);
|
await loadMessages(activeId);
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
|
setLiveNotices([]);
|
||||||
}
|
}
|
||||||
if (chunk.event === "error") {
|
if (chunk.event === "error") {
|
||||||
throw new Error(chunk.data.message);
|
throw new Error(chunk.data.message);
|
||||||
@@ -90,17 +117,23 @@ export default function Chat() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
|
setLiveNotices([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const visibleMessages = messages.filter(shouldShowMessage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-layout">
|
<div className="chat-layout">
|
||||||
<aside className="chat-sidebar">
|
<aside className="chat-sidebar">
|
||||||
<button className="primary-btn" onClick={handleNewChat}>
|
<button className="primary-btn" onClick={handleNewChat}>
|
||||||
+ Новый чат
|
+ Новый чат
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<PomodoroWidget />
|
||||||
|
|
||||||
<ul className="session-list">
|
<ul className="session-list">
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<li key={session.id} className={activeId === session.id ? "active" : ""}>
|
<li key={session.id} className={activeId === session.id ? "active" : ""}>
|
||||||
@@ -119,11 +152,11 @@ export default function Chat() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="messages">
|
<div className="messages">
|
||||||
{messages.map((msg) => (
|
{visibleMessages.map((msg) => (
|
||||||
<div key={msg.id} className={`message message-${msg.role}`}>
|
<div key={msg.id} className={`message message-${msg.role}`}>
|
||||||
<div className="message-role">{msg.role}</div>
|
<div className="message-role">{roleLabel(msg.role)}</div>
|
||||||
<div className="message-content">
|
<div className="message-content">
|
||||||
{msg.role === "assistant" ? (
|
{msg.role === "assistant" || msg.role === "notice" ? (
|
||||||
<ReactMarkdown>{msg.content}</ReactMarkdown>
|
<ReactMarkdown>{msg.content}</ReactMarkdown>
|
||||||
) : (
|
) : (
|
||||||
msg.content
|
msg.content
|
||||||
@@ -131,6 +164,16 @@ export default function Chat() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{liveNotices.map((notice, idx) => (
|
||||||
|
<div key={`notice-${idx}`} className="message message-notice">
|
||||||
|
<div className="message-role">таймер</div>
|
||||||
|
<div className="message-content">
|
||||||
|
<ReactMarkdown>{notice}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{streaming && (
|
{streaming && (
|
||||||
<div className="message message-assistant">
|
<div className="message message-assistant">
|
||||||
<div className="message-role">assistant</div>
|
<div className="message-role">assistant</div>
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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 const DEFAULT_CARD: CharacterCardV2 = {
|
||||||
|
spec: "chara_card_v2",
|
||||||
|
spec_version: "2.0",
|
||||||
|
data: {
|
||||||
|
name: "Домашний ассистент",
|
||||||
|
description:
|
||||||
|
"Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.",
|
||||||
|
personality: "Тёплый, остроумный, по делу. Говорит на русском.",
|
||||||
|
scenario: "Пользователь общается с ассистентом дома через веб-интерфейс.",
|
||||||
|
first_mes: "Привет! Чем займёмся — поболтаем или заведём помидоро?",
|
||||||
|
mes_example: "",
|
||||||
|
system_prompt: "",
|
||||||
|
post_history_instructions: "",
|
||||||
|
tags: ["assistant", "home"],
|
||||||
|
creator: "",
|
||||||
|
creator_notes: "",
|
||||||
|
alternate_greetings: [],
|
||||||
|
character_version: "1.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeCard(raw: CharacterCardV2 | Record<string, unknown>): CharacterCardV2 {
|
||||||
|
if (raw.data && typeof raw.data === "object") {
|
||||||
|
return {
|
||||||
|
spec: (raw.spec as string) ?? "chara_card_v2",
|
||||||
|
spec_version: (raw.spec_version as string) ?? "2.0",
|
||||||
|
data: { ...DEFAULT_CARD.data, ...(raw.data as Partial<CharacterCardData>) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
spec: "chara_card_v2",
|
||||||
|
spec_version: "2.0",
|
||||||
|
data: { ...DEFAULT_CARD.data, ...(raw as Partial<CharacterCardData>) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPngTextChunks(buffer: ArrayBuffer): Map<string, string> {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
|
||||||
|
let offset = 8;
|
||||||
|
while (offset + 12 <= bytes.length) {
|
||||||
|
const length =
|
||||||
|
(bytes[offset] << 24) |
|
||||||
|
(bytes[offset + 1] << 16) |
|
||||||
|
(bytes[offset + 2] << 8) |
|
||||||
|
bytes[offset + 3];
|
||||||
|
const type = String.fromCharCode(
|
||||||
|
bytes[offset + 4],
|
||||||
|
bytes[offset + 5],
|
||||||
|
bytes[offset + 6],
|
||||||
|
bytes[offset + 7]
|
||||||
|
);
|
||||||
|
const dataStart = offset + 8;
|
||||||
|
const dataEnd = dataStart + length;
|
||||||
|
|
||||||
|
if (type === "tEXt") {
|
||||||
|
const chunk = bytes.slice(dataStart, dataEnd);
|
||||||
|
const zero = chunk.indexOf(0);
|
||||||
|
if (zero > 0) {
|
||||||
|
const keyword = new TextDecoder().decode(chunk.slice(0, zero));
|
||||||
|
const text = new TextDecoder().decode(chunk.slice(zero + 1));
|
||||||
|
result.set(keyword, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = dataEnd + 4;
|
||||||
|
if (type === "IEND") break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseCharacterFile(file: File): Promise<CharacterCardV2> {
|
||||||
|
if (file.name.endsWith(".json")) {
|
||||||
|
const text = await file.text();
|
||||||
|
return normalizeCard(JSON.parse(text) as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === "image/png" || file.name.endsWith(".png")) {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const chunks = readPngTextChunks(buffer);
|
||||||
|
const encoded = chunks.get("chara") ?? chunks.get("ccv3");
|
||||||
|
if (!encoded) {
|
||||||
|
throw new Error("В PNG нет поля chara/ccv3 (не Tavern/Chub карточка)");
|
||||||
|
}
|
||||||
|
const json = atob(encoded);
|
||||||
|
return normalizeCard(JSON.parse(json) as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Поддерживаются файлы .json и .png (chara_card_v2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportCardJson(card: CharacterCardV2): void {
|
||||||
|
const blob = new Blob([JSON.stringify(card, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${card.data.name || "character"}.json`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export 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")}`;
|
||||||
|
}
|
||||||
@@ -1 +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"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/hooks/usepomodoro.ts","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/pomodoro.tsx","./src/utils/charactercard.ts","./src/utils/time.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user