232 lines
9.0 KiB
TypeScript
232 lines
9.0 KiB
TypeScript
"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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.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;
|
||
}
|