添加常用功能面板
This commit is contained in:
@@ -36,6 +36,8 @@ import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
||||
import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||
import MicRounded from "@mui/icons-material/MicRounded";
|
||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||
|
||||
// Logic
|
||||
import { streamCopilotChat } from "@/lib/chatStream";
|
||||
@@ -113,6 +115,12 @@ type PersistedChatState = {
|
||||
conversationId?: string;
|
||||
};
|
||||
|
||||
const PRESET_PROMPTS = [
|
||||
"帮我分析当前管网压力异常点,并按风险等级排序。",
|
||||
"基于当前状态,给出今天的巡检优先级和建议路线。",
|
||||
"帮我生成一份今日运行简报,包含问题、原因和建议。",
|
||||
];
|
||||
|
||||
const getInitialChatState = (): PersistedChatState => {
|
||||
if (typeof window === "undefined") {
|
||||
return { messages: [], conversationId: undefined };
|
||||
@@ -537,6 +545,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
initialChatStateRef.current.conversationId
|
||||
);
|
||||
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -590,79 +599,88 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
}
|
||||
}, [messages, conversationId]);
|
||||
|
||||
const sendPrompt = useCallback(
|
||||
async (rawPrompt: string) => {
|
||||
const prompt = rawPrompt.trim();
|
||||
if (!prompt || isStreaming) return;
|
||||
stopListening();
|
||||
|
||||
const userId = createId();
|
||||
const assistantId = createId();
|
||||
setInput("");
|
||||
setIsStreaming(true);
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: userId, role: "user", content: prompt },
|
||||
{ id: assistantId, role: "assistant", content: "" },
|
||||
]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
await streamCopilotChat({
|
||||
message: prompt,
|
||||
conversationId,
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => {
|
||||
if (event.type === "token") {
|
||||
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
|
||||
const normalizedToken = normalizeThoughtTagToken(event.content);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: m.content + normalizedToken, isError: false }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} else if (event.type === "done") {
|
||||
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
|
||||
setIsStreaming(false);
|
||||
} else if (event.type === "error") {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId
|
||||
? {
|
||||
...m,
|
||||
content: m.content || `⚠️ **错误:** ${event.message}`,
|
||||
isError: true,
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (abortRef.current?.signal.aborted) {
|
||||
setMessages((prev) =>
|
||||
prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0))
|
||||
);
|
||||
return;
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true }
|
||||
: m
|
||||
)
|
||||
);
|
||||
setIsStreaming(false);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[conversationId, isStreaming, stopListening],
|
||||
);
|
||||
|
||||
const handleSend = async () => {
|
||||
const prompt = input.trim();
|
||||
if (!prompt || isStreaming) return;
|
||||
stopListening();
|
||||
|
||||
const userId = createId();
|
||||
const assistantId = createId();
|
||||
setInput("");
|
||||
setIsStreaming(true);
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: userId, role: "user", content: prompt },
|
||||
{ id: assistantId, role: "assistant", content: "" },
|
||||
]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
await streamCopilotChat({
|
||||
message: prompt,
|
||||
conversationId,
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => {
|
||||
if (event.type === "token") {
|
||||
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
|
||||
const normalizedToken = normalizeThoughtTagToken(event.content);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: m.content + normalizedToken, isError: false }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} else if (event.type === "done") {
|
||||
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
|
||||
setIsStreaming(false);
|
||||
} else if (event.type === "error") {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId
|
||||
? {
|
||||
...m,
|
||||
content: m.content || `⚠️ **错误:** ${event.message}`,
|
||||
isError: true,
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (abortRef.current?.signal.aborted) {
|
||||
setMessages((prev) =>
|
||||
prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0))
|
||||
);
|
||||
return;
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true }
|
||||
: m
|
||||
)
|
||||
);
|
||||
setIsStreaming(false);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
}
|
||||
await sendPrompt(prompt);
|
||||
};
|
||||
|
||||
const handleAbort = () => {
|
||||
@@ -670,6 +688,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
setIsStreaming(false);
|
||||
};
|
||||
|
||||
const handlePresetPromptSelect = useCallback((prompt: string) => {
|
||||
setInput(prompt);
|
||||
setIsPresetPanelOpen(false);
|
||||
window.setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const handleHeaderMenuOpen = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
setHeaderMenuAnchorEl(event.currentTarget);
|
||||
@@ -913,7 +939,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
</Box>
|
||||
|
||||
{/* Messages - Bouncy List */}
|
||||
<Box sx={{ flex: 1, overflowY: "auto", px: 2.5, py: 2, display: "flex", flexDirection: "column", gap: 2.5, zIndex: 5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
px: 2.5,
|
||||
py: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2.5,
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.length === 0 && (
|
||||
<motion.div
|
||||
@@ -980,6 +1017,104 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
|
||||
{/* Input Area - Floating Capsule */}
|
||||
<Box sx={{ p: 3, zIndex: 10 }}>
|
||||
<Box sx={{ mb: 1.25, display: "flex", justifyContent: "flex-end" }}>
|
||||
<Box sx={{ position: "relative", width: "100%", maxWidth: 520, display: "flex", justifyContent: "flex-end" }}>
|
||||
<AnimatePresence initial={false}>
|
||||
{isPresetPanelOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 26 }}
|
||||
style={{ position: "absolute", right: 0, bottom: "calc(100% + 10px)", width: "100%", zIndex: 3 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={12}
|
||||
sx={{
|
||||
p: 1.2,
|
||||
borderRadius: 3,
|
||||
bgcolor: alpha("#fff", 0.92),
|
||||
backdropFilter: "blur(12px)",
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||
boxShadow: `0 20px 48px -20px ${alpha(theme.palette.common.black, 0.3)}`,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={0.8}>
|
||||
{PRESET_PROMPTS.map((prompt, index) => (
|
||||
<Box
|
||||
key={`preset-${index}`}
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => handlePresetPromptSelect(prompt)}
|
||||
sx={{
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
px: 1.1,
|
||||
py: 0.9,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.24)}`,
|
||||
bgcolor: alpha("#fff", 0.72),
|
||||
color: "text.secondary",
|
||||
fontSize: "0.84rem",
|
||||
lineHeight: 1.45,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.18s ease",
|
||||
"&:hover": {
|
||||
borderColor: alpha(theme.palette.primary.main, 0.45),
|
||||
color: "text.primary",
|
||||
transform: "translateY(-1px)",
|
||||
boxShadow: `0 8px 24px -16px ${alpha(theme.palette.primary.main, 0.6)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{prompt}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div whileHover={{ y: -1 }} whileTap={{ scale: 0.98 }}>
|
||||
<Paper
|
||||
elevation={10}
|
||||
sx={{
|
||||
borderRadius: 99,
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
||||
bgcolor: alpha("#fff", 0.9),
|
||||
backdropFilter: "blur(10px)",
|
||||
boxShadow: `0 14px 40px -14px ${alpha(theme.palette.primary.main, 0.35)}`,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ pl: 1.2, pr: 0.5, py: 0.5 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.secondary.main})`,
|
||||
}}
|
||||
>
|
||||
<AutoAwesome sx={{ fontSize: 16, color: "#fff" }} />
|
||||
</Avatar>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.2 }}>
|
||||
常用功能
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setIsPresetPanelOpen((prev) => !prev)}
|
||||
aria-label={isPresetPanelOpen ? "收起常用功能" : "展开常用功能"}
|
||||
sx={{ color: "text.secondary" }}
|
||||
>
|
||||
{isPresetPanelOpen ? <KeyboardArrowDownRounded /> : <KeyboardArrowUpRounded />}
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
|
||||
Reference in New Issue
Block a user