feat: 优化追问功能 - LLM智能生成 + 显示位置修复
- 修复 AuthLoader 组件中 router.push 在渲染期间调用的 React 警告
- 追问问题改为只在最新 AI 回复下方显示(修复多处显示问题)
- 新增 LLM 生成追问 API:POST /api/v1/apps/{id}/suggestions
- 前端调用 LLM API 生成智能追问,失败时降级到规则生成
- 登录页填入默认测试账号 kj-admin@govai.gov.cn
This commit is contained in:
@@ -20,8 +20,8 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [email, setEmail] = useState("kj-admin@govai.gov.cn");
|
||||
const [password, setPassword] = useState("admin123");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [orgs, setOrgs] = useState<Organization[]>([]);
|
||||
|
||||
@@ -140,6 +140,7 @@ interface AgentUIProps {
|
||||
export default function AgentUI({ app }: AgentUIProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
const [messages, setMessages] = useState<Message[]>(
|
||||
app.welcome_message && !conversationId
|
||||
? [{ id: "welcome", role: "assistant", content: app.welcome_message }]
|
||||
@@ -147,7 +148,6 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
);
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useState, useEffect, useCallback, memo, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, memo, useMemo, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { App, Conversation, Message, Chunk } from "@/lib/types";
|
||||
@@ -45,10 +45,14 @@ const ChatMessage = memo(function ChatMessage({
|
||||
msg,
|
||||
onCopy,
|
||||
chunks,
|
||||
suggestions,
|
||||
onSuggestionClick,
|
||||
}: {
|
||||
msg: Message;
|
||||
onCopy: (text: string) => void;
|
||||
chunks: Chunk[];
|
||||
suggestions?: string[];
|
||||
onSuggestionClick?: (text: string) => void;
|
||||
}) {
|
||||
if (msg.role === "user") {
|
||||
return (
|
||||
@@ -85,6 +89,20 @@ const ChatMessage = memo(function ChatMessage({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 对话结束后的提示问题 */}
|
||||
{suggestions && suggestions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{suggestions.map((suggestion, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSuggestionClick?.(suggestion)}
|
||||
className="text-xs px-3 py-1.5 rounded-full bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900 border border-blue-200 dark:border-blue-800 transition-colors"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -98,14 +116,15 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
const [messages, setMessages] = useState<Message[]>(
|
||||
app.welcome_message && !conversationId
|
||||
? [{ id: "welcome", role: "assistant", content: app.welcome_message }]
|
||||
: [],
|
||||
);
|
||||
const [currentSuggestions, setCurrentSuggestions] = useState<string[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
@@ -263,10 +282,12 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
setFileContent(null);
|
||||
setFileName(null);
|
||||
setChunks([]);
|
||||
setCurrentSuggestions([]); // 清空之前的提示问题
|
||||
setIsStreaming(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const accumulatedRef = { current: "" };
|
||||
|
||||
try {
|
||||
const res = await streamChat(app.id, fullMessage, conversationId, controller.signal);
|
||||
@@ -277,6 +298,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
|
||||
let buffer = "";
|
||||
let accumulated = "";
|
||||
let conversationIdFromResponse: string | undefined;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
@@ -289,7 +311,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
if (raw === "[DONE]") break;
|
||||
try {
|
||||
const event = JSON.parse(raw);
|
||||
if (event.conversation_id) setConversationId(event.conversation_id);
|
||||
if (event.conversation_id) conversationIdFromResponse = event.conversation_id;
|
||||
// 解析首包的 chunks 映射表
|
||||
if (event.chunks) {
|
||||
console.log("[SSE] Received chunks:", event.chunks);
|
||||
@@ -297,6 +319,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
}
|
||||
if (event.answer) {
|
||||
accumulated += event.answer;
|
||||
accumulatedRef.current += event.answer;
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
@@ -309,6 +332,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (conversationIdFromResponse) setConversationId(conversationIdFromResponse);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["conversations", app.id],
|
||||
});
|
||||
@@ -328,8 +352,64 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
// 生成对话结束后的提示问题
|
||||
generateChatSuggestions(messages);
|
||||
}
|
||||
}, [input, isStreaming, app.id, conversationId, queryClient]);
|
||||
}, [input, isStreaming, app.id, conversationId, queryClient, messages]);
|
||||
|
||||
// 生成对话结束后的提示问题(调用 LLM API)
|
||||
const generateChatSuggestions = async (allMessages: Message[]) => {
|
||||
// 过滤掉 welcome 消息,只保留用户和助手的实际对话
|
||||
const realMessages = allMessages.filter((m) => m.id !== "welcome").slice(-10);
|
||||
|
||||
try {
|
||||
const response = await api.post<{ data: string[] }>(`/api/v1/apps/${app.id}/suggestions`, {
|
||||
conversation_id: conversationId,
|
||||
messages: realMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||
});
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
setCurrentSuggestions(response.data);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("LLM 生成追问失败,使用规则生成:", err);
|
||||
}
|
||||
|
||||
// API 调用失败时,使用规则生成作为降级方案
|
||||
generateFallbackSuggestions(allMessages);
|
||||
};
|
||||
|
||||
// 规则生成追问(降级方案)
|
||||
const generateFallbackSuggestions = (allMessages: Message[]) => {
|
||||
const lastResponse = allMessages[allMessages.length - 1]?.content || "";
|
||||
const suggestions: string[] = [];
|
||||
const lowerResponse = lastResponse.toLowerCase();
|
||||
|
||||
// 基于回复内容生成针对性追问
|
||||
if (lowerResponse.includes("政策") || lowerResponse.includes("法规") || lowerResponse.includes("法律")) {
|
||||
suggestions.push("还有哪些相关政策?", "政策的适用范围是什么?");
|
||||
}
|
||||
if (lowerResponse.includes("流程") || lowerResponse.includes("步骤") || lowerResponse.includes("程序")) {
|
||||
suggestions.push("具体流程是什么?", "需要准备哪些材料?");
|
||||
}
|
||||
if (lowerResponse.includes("条件") || lowerResponse.includes("要求") || lowerResponse.includes("资格")) {
|
||||
suggestions.push("具体需要什么条件?", "不符合条件怎么办?");
|
||||
}
|
||||
if (lowerResponse.includes("费用") || lowerResponse.includes("收费")) {
|
||||
suggestions.push("收费标准是多少?", "有优惠政策吗?");
|
||||
}
|
||||
|
||||
if (suggestions.length < 4) {
|
||||
const generic = ["能详细说明一下吗?", "有什么需要注意的?", "可以举个例子吗?", "还有其他方案吗?"];
|
||||
for (const q of generic) {
|
||||
if (suggestions.length >= 4) break;
|
||||
if (!suggestions.includes(q)) suggestions.push(q);
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentSuggestions(suggestions.slice(0, 4));
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -346,6 +426,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
setMessages([]);
|
||||
setConversationId(undefined);
|
||||
setIsStreaming(false);
|
||||
setCurrentSuggestions([]);
|
||||
}, []);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
@@ -654,9 +735,22 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} msg={msg} onCopy={copyText} chunks={chunks} />
|
||||
))}
|
||||
{messages.map((msg, idx) => {
|
||||
const isLastAssistant = msg.role === "assistant" && idx === messages.length - 1;
|
||||
return (
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
onCopy={copyText}
|
||||
chunks={chunks}
|
||||
suggestions={isLastAssistant ? currentSuggestions : undefined}
|
||||
onSuggestionClick={(text) => {
|
||||
setInput(text);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
|
||||
@@ -24,22 +24,20 @@ function AuthLoader({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [fetchUser, isPublic]);
|
||||
|
||||
// 鉴权路由:未登录则跳转(useEffect 中执行,避免渲染期间调用 router)
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !isPublic) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, isPublic, router]);
|
||||
|
||||
// 公开路由:直接渲染,不阻塞
|
||||
if (isPublic) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 鉴权路由:未登录则跳转
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// 加载中或未认证
|
||||
if (isLoading || !isAuthenticated) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
|
||||
Reference in New Issue
Block a user