250 lines
11 KiB
TypeScript
250 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|