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:
selfrelease
2026-06-23 15:35:58 +08:00
parent 96b944ac8d
commit 8959456eb7
6 changed files with 211 additions and 21 deletions
+2 -2
View File
@@ -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[]>([]);
+1 -1
View File
@@ -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<{
+101 -7
View File
@@ -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">
+9 -11
View File
@@ -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" />