astock-agent/frontend/src/app/chat/page.tsx
2026-04-07 21:37:44 +08:00

232 lines
9.0 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.

"use client";
import { useState, useRef, useEffect } from "react";
import { streamChat, type ChatMessage } from "@/lib/api";
interface DisplayMessage {
role: "user" | "assistant";
content: string;
}
const QUICK_QUESTIONS = [
"今日市场怎么样?",
"有哪些推荐股票?",
"哪些板块最热门?",
];
export default function ChatPage() {
const [messages, setMessages] = useState<DisplayMessage[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [status, setStatus] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollToBottom = () => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
};
useEffect(() => {
scrollToBottom();
}, [messages, status]);
const sendMessage = async (text: string) => {
if (!text.trim() || streaming) return;
const userMsg: DisplayMessage = { role: "user", content: text.trim() };
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setInput("");
setStreaming(true);
setStatus("");
// Add empty assistant message for streaming
setMessages([...newMessages, { role: "assistant", content: "" }]);
try {
const chatMessages: ChatMessage[] = newMessages.map((m) => ({
role: m.role,
content: m.content,
}));
let fullContent = "";
for await (const event of streamChat(chatMessages)) {
if (event.type === "status") {
setStatus(event.content);
} else if (event.type === "content") {
fullContent += event.content;
setMessages([
...newMessages,
{ role: "assistant", content: fullContent },
]);
setStatus("");
}
}
} catch (e) {
console.error("Chat error:", e);
setMessages([
...newMessages,
{ role: "assistant", content: "连接失败,请检查网络后重试。" },
]);
} finally {
setStreaming(false);
setStatus("");
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage(input);
}
};
return (
<div className="max-w-3xl mx-auto flex flex-col md:h-[calc(100dvh)]">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-white/[0.04] bg-bg-primary/80 backdrop-blur-xl">
<div className="flex items-center gap-3">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-accent-indigo/30 to-accent-indigo/10 flex items-center justify-center border border-accent-indigo/20">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-accent-indigo/70">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</div>
<div>
<h1 className="text-sm font-semibold">AI </h1>
<p className="text-xs text-text-muted">
</p>
</div>
</div>
{messages.length > 0 && (
<button
onClick={() => setMessages([])}
className="text-xs text-text-muted hover:text-text-primary px-3 py-1.5 rounded-lg hover:bg-white/[0.04] transition-all duration-200"
>
</button>
)}
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-5 space-y-4">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center animate-fade-in-up">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-accent-indigo/15 to-accent-indigo/5 flex items-center justify-center mb-5 border border-accent-indigo/10">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-accent-indigo/50">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</div>
<h2 className="text-sm font-semibold mb-1.5"></h2>
<p className="text-xs text-text-muted mb-8 max-w-[240px] leading-relaxed">
</p>
<div className="flex flex-col gap-2 w-full max-w-[280px]">
{QUICK_QUESTIONS.map((q) => (
<button
key={q}
onClick={() => sendMessage(q)}
className="text-xs px-4 py-2.5 bg-white/[0.03] rounded-xl text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all duration-200 border border-white/[0.04] text-left"
>
{q}
</button>
))}
</div>
</div>
) : (
<>
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"} animate-fade-in-up`}
>
<div
className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-[13px] leading-relaxed ${
msg.role === "user"
? "bg-gradient-to-r from-orange-500/20 to-amber-500/15 text-orange-100 border border-orange-500/10"
: "glass-card-static"
}`}
>
{msg.role === "assistant" ? (
msg.content ? (
<div
className="prose prose-invert prose-sm max-w-none [&_p]:my-1 [&_ul]:my-1 [&_li]:my-0.5 [&_strong]:text-orange-300"
dangerouslySetInnerHTML={{
__html: formatMarkdown(msg.content),
}}
/>
) : (
<span className="text-text-muted/50 text-xs">
{status || "思考中..."}
</span>
)
) : (
<span>{msg.content}</span>
)}
{streaming && i === messages.length - 1 && msg.role === "assistant" && msg.content && (
<span className="inline-block w-1.5 h-4 bg-accent-indigo/60 ml-0.5 animate-pulse rounded-full" />
)}
</div>
</div>
))}
{/* Status indicator during tool calls */}
{streaming && status && messages[messages.length - 1]?.content && (
<div className="flex justify-start">
<div className="text-xs text-accent-indigo/50 flex items-center gap-2 px-3">
<span className="inline-block w-2.5 h-2.5 border border-accent-indigo/30 border-t-accent-indigo/70 rounded-full animate-spin" />
{status}
</div>
</div>
)}
</>
)}
</div>
{/* Input */}
<div className="px-5 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-white/[0.04] bg-bg-primary/80 backdrop-blur-xl">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入问题..."
rows={1}
className="flex-1 bg-white/[0.03] rounded-xl px-4 py-2.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-accent-indigo/30 placeholder-text-muted/40 border border-white/[0.04] transition-all duration-200"
disabled={streaming}
/>
<button
onClick={() => sendMessage(input)}
disabled={!input.trim() || streaming}
className="px-4 py-2.5 bg-gradient-to-r from-accent-indigo/20 to-accent-indigo/10 text-accent-indigo rounded-xl text-sm hover:from-accent-indigo/30 hover:to-accent-indigo/20 disabled:opacity-20 transition-all duration-200 shrink-0 border border-accent-indigo/10 font-medium"
>
</button>
</div>
<div className="text-xs text-text-muted/30 text-center mt-2">
AI
</div>
</div>
</div>
);
}
/** Simple markdown to HTML (bold, lists, newlines) */
function formatMarkdown(text: string): string {
let html = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^\s*[-*]\s+(.+)/gm, "<li>$1</li>")
.replace(/\n/g, "<br>");
// Wrap consecutive <li> items in <ul>
html = html.replace(/(<li>.*?<\/li>(<br>)?)+/g, (match) => {
return "<ul>" + match.replace(/<br>/g, "") + "</ul>";
});
return html;
}