This commit is contained in:
2026-06-09 11:26:28 +03:00
parent 94735fd540
commit 244935e4ac
21 changed files with 886 additions and 15 deletions
+1
View File
@@ -21,6 +21,7 @@
.app-header nav {
display: flex;
align-items: center;
gap: 0.75rem;
}
+5
View File
@@ -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() {
Чат
</NavLink>
<NavLink to="/pomodoro">Помидоро</NavLink>
<NavLink to="/character">Персонаж</NavLink>
<PomodoroWidget compact />
</nav>
</header>
<main className="app-main">
<Routes>
<Route path="/" element={<Chat />} />
<Route path="/pomodoro" element={<Pomodoro />} />
<Route path="/character" element={<Character />} />
</Routes>
</main>
</div>
+31
View File
@@ -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<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>
);
}
+27
View File
@@ -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 };
}
+73
View File
@@ -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;
}
+186
View File
@@ -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>&#10;{{user}}: Привет&#10;{{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>
);
}
+10 -2
View File
@@ -11,6 +11,7 @@
flex-direction: column;
gap: 1rem;
background: #12151c;
min-height: 0;
}
.primary-btn {
@@ -92,13 +93,20 @@
background: #2b4acb;
}
.message-assistant,
.message-tool {
.message-assistant {
align-self: flex-start;
background: #1b2130;
border: 1px solid #2a3142;
}
.message-notice {
align-self: center;
max-width: 90%;
background: #1a2a1f;
border: 1px solid #2d5a3d;
font-size: 0.92rem;
}
.message-role {
font-size: 0.75rem;
color: #8b95a5;
+47 -4
View File
@@ -1,8 +1,22 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { api, ChatMessage, ChatSession } from "../api/client";
import PomodoroWidget from "../components/PomodoroWidget";
import { usePomodoro } from "../hooks/usePomodoro";
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() {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeId, setActiveId] = useState<number | null>(null);
@@ -10,7 +24,9 @@ export default function Chat() {
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState("");
const [liveNotices, setLiveNotices] = useState<string[]>([]);
const bottomRef = useRef<HTMLDivElement>(null);
const { refresh: refreshPomodoro } = usePomodoro();
const loadSessions = async () => {
const data = await api.listSessions();
@@ -37,13 +53,14 @@ export default function Chat() {
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streaming]);
}, [messages, streaming, liveNotices]);
const handleNewChat = async () => {
const session = await api.createSession();
await loadSessions();
setActiveId(session.id);
setMessages([]);
setLiveNotices([]);
};
const handleDelete = async (id: number) => {
@@ -53,6 +70,7 @@ export default function Chat() {
if (activeId === id) {
setActiveId(data[0]?.id ?? null);
setMessages([]);
setLiveNotices([]);
}
};
@@ -64,6 +82,7 @@ export default function Chat() {
setInput("");
setLoading(true);
setStreaming("");
setLiveNotices([]);
const tempUser: ChatMessage = {
id: Date.now(),
@@ -78,10 +97,18 @@ export default function Chat() {
if (chunk.event === "token") {
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") {
await loadMessages(activeId);
await loadSessions();
setStreaming("");
setLiveNotices([]);
}
if (chunk.event === "error") {
throw new Error(chunk.data.message);
@@ -90,17 +117,23 @@ export default function Chat() {
} catch (err) {
console.error(err);
setStreaming("");
setLiveNotices([]);
} finally {
setLoading(false);
}
};
const visibleMessages = messages.filter(shouldShowMessage);
return (
<div className="chat-layout">
<aside className="chat-sidebar">
<button className="primary-btn" onClick={handleNewChat}>
+ Новый чат
</button>
<PomodoroWidget />
<ul className="session-list">
{sessions.map((session) => (
<li key={session.id} className={activeId === session.id ? "active" : ""}>
@@ -119,11 +152,11 @@ export default function Chat() {
) : (
<>
<div className="messages">
{messages.map((msg) => (
{visibleMessages.map((msg) => (
<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">
{msg.role === "assistant" ? (
{msg.role === "assistant" || msg.role === "notice" ? (
<ReactMarkdown>{msg.content}</ReactMarkdown>
) : (
msg.content
@@ -131,6 +164,16 @@ export default function Chat() {
</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 && (
<div className="message message-assistant">
<div className="message-role">assistant</div>
+124
View File
@@ -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);
}
+5
View File
@@ -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
View File
@@ -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"}