237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
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>
|
||
);
|
||
}
|