astock-agent/frontend/src/app/(auth)/chat/page.tsx
2026-05-14 11:10:17 +08:00

250 lines
11 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 { useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
import { formatMarkdown } from "@/lib/markdown";
import { streamChat, type ChatMessage } from "@/lib/api";
interface DisplayMessage {
role: "user" | "assistant";
content: string;
}
const QUICK_QUESTIONS = [
"结合今日作战结论,告诉我今天应该重点看什么。",
"诊断一下 300750.SZ给出触发条件和失效条件。",
"看看我的自选股里哪些需要明天优先盯盘。",
"复盘当前推荐池,哪些信号最近更有效?",
];
const CHAT_SCENES = [
{
title: "市场",
description: "打法 / 仓位 / 风险",
},
{
title: "个股",
description: "诊断 / 触发 / 失效",
},
{
title: "自选",
description: "推荐池 / 自选股 / 复盘",
},
];
export default function ChatPage() {
const { theme } = useTheme();
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) => {
const content = text.trim();
if (!content || streaming) return;
const userMsg: DisplayMessage = { role: "user", content };
const newMessages = [...messages, userMsg];
setMessages([...newMessages, { role: "assistant", content: "" }]);
setInput("");
setStreaming(true);
setStatus("");
try {
const chatMessages: ChatMessage[] = newMessages.map((message) => ({
role: message.role,
content: message.content,
}));
let fullContent = "";
for await (const event of streamChat(chatMessages)) {
if (event.type === "status") {
setStatus(event.content);
continue;
}
fullContent += event.content;
setMessages([
...newMessages,
{ role: "assistant", content: fullContent },
]);
}
} catch (error) {
console.error("Chat error:", error);
setMessages([
...newMessages,
{ role: "assistant", content: "连接失败,暂时无法读取作战数据,请稍后重试。" },
]);
} finally {
setStreaming(false);
setStatus("");
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage(input);
}
};
return (
<div className="mx-auto flex h-[100dvh] max-w-7xl flex-col px-4 pb-20 pt-6 md:px-8 md:pb-10">
<div className="grid min-h-0 flex-1 gap-5 xl:grid-cols-[320px_minmax(0,1fr)]">
<aside className="hidden xl:flex xl:flex-col xl:gap-4">
<div className="glass-card-static p-5 animate-fade-in-up">
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-cyan-400">
Research Desk
</div>
<h1 className="mt-2 text-xl font-bold tracking-tight"></h1>
</div>
<div className="glass-card-static p-5 animate-fade-in-up">
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400"></div>
<div className="mt-4 space-y-3">
{CHAT_SCENES.map((scene) => (
<div key={scene.title} className="rounded-2xl border border-border-subtle bg-surface-1/70 px-4 py-3">
<div className="text-sm font-semibold text-text-primary">{scene.title}</div>
<div className="mt-1 text-xs leading-6 text-text-secondary">{scene.description}</div>
</div>
))}
</div>
</div>
</aside>
<section className="glass-card-static flex min-h-0 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3.5 sm:px-5">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-xl border border-cyan-400/15 bg-gradient-to-br from-cyan-400/15 to-cyan-400/5">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" className="text-cyan-300/80">
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
</svg>
</div>
<div>
<h2 className="text-sm font-semibold">A股研究助手</h2>
<p className="text-xs text-text-muted">
/ / /
</p>
</div>
</div>
{messages.length > 0 ? (
<button
onClick={() => setMessages([])}
className="rounded-lg px-3 py-1.5 text-xs text-text-muted transition-all duration-200 hover:bg-surface-3 hover:text-text-primary"
>
</button>
) : null}
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5">
{messages.length === 0 ? (
<div className="flex h-full flex-col justify-center">
<div className="mx-auto max-w-2xl text-center animate-fade-in-up">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-[20px] border border-cyan-400/10 bg-gradient-to-br from-cyan-400/12 to-transparent">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-cyan-300/60">
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold">线</h3>
</div>
<div className="mx-auto mt-8 grid w-full max-w-3xl gap-3 md:grid-cols-2">
{QUICK_QUESTIONS.map((question) => (
<button
key={question}
onClick={() => sendMessage(question)}
className="rounded-2xl border border-border-subtle bg-surface-1/70 px-4 py-4 text-left text-sm text-text-secondary transition-all duration-200 hover:border-cyan-400/20 hover:bg-surface-3 hover:text-text-primary"
>
{question}
</button>
))}
</div>
</div>
) : (
<div className="space-y-4">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-fade-in-up`}
>
<div
className={`max-w-[92%] rounded-2xl px-4 py-3 text-[13px] leading-relaxed sm:max-w-[82%] ${
message.role === "user"
? "border border-amber-500/10 bg-gradient-to-r from-amber-500/20 to-orange-500/15 text-orange-100"
: "border border-border-subtle bg-surface-1/80"
}`}
>
{message.role === "assistant" ? (
message.content ? (
<div
className={`prose prose-sm max-w-none ${theme !== "light" ? "prose-invert" : ""} [&_p]:my-1.5 [&_ul]:my-2 [&_li]:my-0.5`}
dangerouslySetInnerHTML={{ __html: formatMarkdown(message.content) }}
/>
) : (
<span className="text-xs text-text-muted/60">{status || "读取作战上下文中..."}</span>
)
) : (
<span>{message.content}</span>
)}
{streaming && index === messages.length - 1 && message.role === "assistant" && message.content ? (
<span className="ml-1 inline-block h-4 w-1.5 animate-pulse rounded-full bg-accent-cyan/60" />
) : null}
</div>
</div>
))}
{streaming && status && messages[messages.length - 1]?.content ? (
<div className="flex justify-start">
<div className="flex items-center gap-2 px-2 text-xs text-accent-cyan/60">
<span className="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-accent-cyan/30 border-t-accent-cyan/80" />
{status}
</div>
</div>
) : null}
</div>
)}
</div>
<div className="border-t border-border-subtle bg-bg-primary/40 px-4 py-3 sm:px-5">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="比如:诊断一下 300750.SZ结合今日主线给触发和失效条件"
rows={1}
disabled={streaming}
className="min-h-[46px] flex-1 resize-none rounded-2xl border border-border-subtle bg-surface-2 px-4 py-3 text-sm transition-all duration-200 placeholder:text-text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent-cyan/30"
/>
<button
onClick={() => sendMessage(input)}
disabled={!input.trim() || streaming}
className="shrink-0 rounded-2xl border border-accent-cyan/10 bg-gradient-to-r from-accent-cyan/20 to-accent-cyan/10 px-4 py-3 text-sm font-medium text-accent-cyan transition-all duration-200 hover:from-accent-cyan/30 hover:to-accent-cyan/20 disabled:opacity-30"
>
</button>
</div>
</div>
</section>
</div>
</div>
);
}