fixed
This commit is contained in:
@@ -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;
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user