Files
Home_assistant/frontend/src/pages/Chat.tsx
T
2026-06-10 10:29:21 +03:00

237 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 "../context/PomodoroContext";
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 noticeLabel(content: string): string {
if (content.startsWith("⏱")) return "таймер";
if (content.startsWith("📋")) return "задачи";
if (content.startsWith("🔀")) return "git";
if (content.startsWith("🧠")) return "память";
if (content.startsWith("💪")) return "фитнес";
if (content.startsWith("🌤")) return "погода";
if (content.startsWith("🎨")) return "картинка";
if (content.startsWith("⚠️")) return "сервер";
return "система";
}
function roleLabel(role: string, content = ""): string {
if (role === "notice") return noticeLabel(content);
if (role === "user") return "вы";
return role;
}
export default function Chat() {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeId, setActiveId] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState("");
const [liveNotices, setLiveNotices] = useState<string[]>([]);
const bottomRef = useRef<HTMLDivElement>(null);
const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro();
const [lastNotifySeq, setLastNotifySeq] = useState(0);
const loadSessions = async () => {
const data = await api.listSessions();
setSessions(data);
if (!activeId && data.length > 0) {
setActiveId(data[0].id);
}
};
const loadMessages = async (sessionId: number) => {
const data = await api.getSession(sessionId);
setMessages(data.messages);
};
useEffect(() => {
loadSessions().catch(console.error);
}, []);
useEffect(() => {
if (activeId) {
loadMessages(activeId).catch(console.error);
}
}, [activeId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streaming, liveNotices]);
useEffect(() => {
const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0;
if (seq > lastNotifySeq) {
setLastNotifySeq(seq);
refreshPomodoro().catch(console.error);
if (activeId) {
loadMessages(activeId).catch(console.error);
}
}
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro]);
const handleNewChat = async () => {
const session = await api.createSession();
await loadSessions();
setActiveId(session.id);
setMessages([]);
setLiveNotices([]);
};
const handleDelete = async (id: number) => {
await api.deleteSession(id);
const data = await api.listSessions();
setSessions(data);
if (activeId === id) {
setActiveId(data[0]?.id ?? null);
setMessages([]);
setLiveNotices([]);
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!input.trim() || !activeId || loading) return;
const text = input.trim();
setInput("");
setLoading(true);
setStreaming("");
setLiveNotices([]);
const tempUser: ChatMessage = {
id: Date.now(),
role: "user",
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, tempUser]);
try {
for await (const chunk of api.sendMessage(activeId, text)) {
if (chunk.event === "token") {
setStreaming((prev) => prev + chunk.data.content);
}
if (chunk.event === "notice") {
setLiveNotices((prev) => [...prev, chunk.data.content]);
if (String(chunk.data.content).startsWith("⏱")) {
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);
}
}
} 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" : ""}>
<button onClick={() => setActiveId(session.id)}>{session.title}</button>
<button className="delete-btn" onClick={() => handleDelete(session.id)}>
×
</button>
</li>
))}
</ul>
</aside>
<section className="chat-main">
{!activeId ? (
<div className="chat-empty">Создайте новый чат, чтобы начать</div>
) : (
<>
<div className="messages">
{visibleMessages.map((msg) => (
<div key={msg.id} className={`message message-${msg.role}`}>
<div className="message-role">{roleLabel(msg.role, msg.content)}</div>
<div className="message-content">
{msg.role === "assistant" || msg.role === "notice" ? (
<ReactMarkdown>{msg.content}</ReactMarkdown>
) : (
msg.content
)}
</div>
</div>
))}
{liveNotices.map((notice, idx) => (
<div key={`notice-${idx}`} className="message message-notice">
<div className="message-role">{noticeLabel(notice)}</div>
<div className="message-content">
<ReactMarkdown>{notice}</ReactMarkdown>
</div>
</div>
))}
{streaming && (
<div className="message message-assistant">
<div className="message-role">assistant</div>
<div className="message-content">
<ReactMarkdown>{streaming}</ReactMarkdown>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
<form className="chat-input" onSubmit={handleSubmit}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение..."
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
/>
<button type="submit" disabled={loading || !input.trim()}>
{loading ? "..." : "Отправить"}
</button>
</form>
</>
)}
</section>
</div>
);
}