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"; } from "lucide-react";
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("kj-admin@govai.gov.cn");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("admin123");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState(""); const [errorMsg, setErrorMsg] = useState("");
const [orgs, setOrgs] = useState<Organization[]>([]); const [orgs, setOrgs] = useState<Organization[]>([]);
+1 -1
View File
@@ -140,6 +140,7 @@ interface AgentUIProps {
export default function AgentUI({ app }: AgentUIProps) { export default function AgentUI({ app }: AgentUIProps) {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [conversationId, setConversationId] = useState<string | undefined>();
const [messages, setMessages] = useState<Message[]>( const [messages, setMessages] = useState<Message[]>(
app.welcome_message && !conversationId app.welcome_message && !conversationId
? [{ id: "welcome", role: "assistant", content: app.welcome_message }] ? [{ id: "welcome", role: "assistant", content: app.welcome_message }]
@@ -147,7 +148,6 @@ export default function AgentUI({ app }: AgentUIProps) {
); );
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>();
const [selectMode, setSelectMode] = useState(false); const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [deleteTarget, setDeleteTarget] = useState<{ const [deleteTarget, setDeleteTarget] = useState<{
+101 -7
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import React from "react"; 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 { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { App, Conversation, Message, Chunk } from "@/lib/types"; import type { App, Conversation, Message, Chunk } from "@/lib/types";
@@ -45,10 +45,14 @@ const ChatMessage = memo(function ChatMessage({
msg, msg,
onCopy, onCopy,
chunks, chunks,
suggestions,
onSuggestionClick,
}: { }: {
msg: Message; msg: Message;
onCopy: (text: string) => void; onCopy: (text: string) => void;
chunks: Chunk[]; chunks: Chunk[];
suggestions?: string[];
onSuggestionClick?: (text: string) => void;
}) { }) {
if (msg.role === "user") { if (msg.role === "user") {
return ( return (
@@ -85,6 +89,20 @@ const ChatMessage = memo(function ChatMessage({
</button> </button>
</div> </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>
</div> </div>
); );
@@ -98,14 +116,15 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user } = useAuthStore(); const { user } = useAuthStore();
const [conversationId, setConversationId] = useState<string | undefined>();
const [messages, setMessages] = useState<Message[]>( const [messages, setMessages] = useState<Message[]>(
app.welcome_message && !conversationId app.welcome_message && !conversationId
? [{ id: "welcome", role: "assistant", content: app.welcome_message }] ? [{ id: "welcome", role: "assistant", content: app.welcome_message }]
: [], : [],
); );
const [currentSuggestions, setCurrentSuggestions] = useState<string[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>();
const [selectMode, setSelectMode] = useState(false); const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [deleteTarget, setDeleteTarget] = useState<{ const [deleteTarget, setDeleteTarget] = useState<{
@@ -263,10 +282,12 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
setFileContent(null); setFileContent(null);
setFileName(null); setFileName(null);
setChunks([]); setChunks([]);
setCurrentSuggestions([]); // 清空之前的提示问题
setIsStreaming(true); setIsStreaming(true);
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
const accumulatedRef = { current: "" };
try { try {
const res = await streamChat(app.id, fullMessage, conversationId, controller.signal); const res = await streamChat(app.id, fullMessage, conversationId, controller.signal);
@@ -277,6 +298,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
let buffer = ""; let buffer = "";
let accumulated = ""; let accumulated = "";
let conversationIdFromResponse: string | undefined;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
@@ -289,7 +311,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
if (raw === "[DONE]") break; if (raw === "[DONE]") break;
try { try {
const event = JSON.parse(raw); const event = JSON.parse(raw);
if (event.conversation_id) setConversationId(event.conversation_id); if (event.conversation_id) conversationIdFromResponse = event.conversation_id;
// 解析首包的 chunks 映射表 // 解析首包的 chunks 映射表
if (event.chunks) { if (event.chunks) {
console.log("[SSE] Received chunks:", event.chunks); console.log("[SSE] Received chunks:", event.chunks);
@@ -297,6 +319,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
} }
if (event.answer) { if (event.answer) {
accumulated += event.answer; accumulated += event.answer;
accumulatedRef.current += event.answer;
const snap = accumulated; const snap = accumulated;
setMessages((prev) => setMessages((prev) =>
prev.map((m, i) => prev.map((m, i) =>
@@ -309,6 +332,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
} }
} }
} }
if (conversationIdFromResponse) setConversationId(conversationIdFromResponse);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["conversations", app.id], queryKey: ["conversations", app.id],
}); });
@@ -328,8 +352,64 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
} finally { } finally {
abortRef.current = null; abortRef.current = null;
setIsStreaming(false); 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( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@@ -346,6 +426,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
setMessages([]); setMessages([]);
setConversationId(undefined); setConversationId(undefined);
setIsStreaming(false); setIsStreaming(false);
setCurrentSuggestions([]);
}, []); }, []);
const toggleSelect = useCallback((id: string) => { 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="flex-1 overflow-y-auto min-h-0 p-4">
<div className="max-w-3xl mx-auto space-y-4"> <div className="max-w-3xl mx-auto space-y-4">
{messages.map((msg) => ( {messages.map((msg, idx) => {
<ChatMessage key={msg.id} msg={msg} onCopy={copyText} chunks={chunks} /> 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 && ( {messages.length <= 1 && suggestedPrompts.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4"> <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]); }, [fetchUser, isPublic]);
// 鉴权路由:未登录则跳转(useEffect 中执行,避免渲染期间调用 router)
useEffect(() => {
if (!isLoading && !isAuthenticated && !isPublic) {
router.push("/login");
}
}, [isLoading, isAuthenticated, isPublic, router]);
// 公开路由:直接渲染,不阻塞 // 公开路由:直接渲染,不阻塞
if (isPublic) { if (isPublic) {
return <>{children}</>; return <>{children}</>;
} }
// 鉴权路由:未登录则跳转 // 加载中或未认证
if (!isLoading && !isAuthenticated) { 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) {
return ( return (
<div className="flex h-screen items-center justify-center"> <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 className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
+1
View File
@@ -132,6 +132,7 @@ func newRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *redis.Client) http.H
r.Post("/generate-analysis", analysisH.GenerateReport) r.Post("/generate-analysis", analysisH.GenerateReport)
r.Get("/conversations", chatH.Conversations) r.Get("/conversations", chatH.Conversations)
r.Get("/conversations/{convId}/messages", chatH.Messages) r.Get("/conversations/{convId}/messages", chatH.Messages)
r.Post("/suggestions", chatH.GetSuggestions)
r.Delete("/conversations/{convId}", chatH.DeleteConversation) r.Delete("/conversations/{convId}", chatH.DeleteConversation)
r.Put("/conversations/{convId}/name", chatH.RenameConversation) r.Put("/conversations/{convId}/name", chatH.RenameConversation)
r.Post("/conversations/batch-delete", chatH.BatchDeleteConversations) r.Post("/conversations/batch-delete", chatH.BatchDeleteConversations)
+97
View File
@@ -1737,3 +1737,100 @@ func (h *LLMChatHandler) generateConversationName(appID, userID, convID, userMes
DO UPDATE SET name = EXCLUDED.name, updated_at = now()`, DO UPDATE SET name = EXCLUDED.name, updated_at = now()`,
appID, userID, convID, name) appID, userID, convID, name)
} }
// GenerateSuggestions 生成追问建议(由 LLM 智能生成)
func (h *LLMChatHandler) GenerateSuggestions(ctx context.Context, conversationID, appID string, messages []map[string]string) ([]string, error) {
if len(messages) == 0 {
return nil, nil
}
// 构建对话历史上下文
var historyBuilder strings.Builder
for i, msg := range messages {
role := "用户"
if msg["role"] == "assistant" {
role = "助手"
}
historyBuilder.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, role, msg["content"]))
}
systemPrompt := `你是一个智能政务问答助手。请根据对话历史,生成4个追问问题,帮助用户深入了解相关内容。
要求:
1. 问题必须与对话内容紧密相关
2. 每个问题不超过20个字
3. 问题要有深度,引导用户进一步思考
4. 避免重复或过于宽泛的问题
5. 直接输出问题,用换行分隔,不要编号,不要解释`
suggestReq := &llm.ChatRequest{
Model: "",
Messages: []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: "对话历史:\n" + historyBuilder.String()},
},
Temperature: 0.7,
MaxTokens: 200,
Stream: false,
}
// 获取激活的 provider
provider, defaultModel, err := h.getProviderWithModel(ctx)
if err != nil {
return nil, err
}
if defaultModel != "" {
suggestReq.Model = defaultModel
}
result, err := h.manager.Chat(ctx, provider, suggestReq)
if err != nil {
return nil, err
}
// 解析结果:按换行分割
suggestions := strings.Split(strings.TrimSpace(result.Content), "\n")
var validSuggestions []string
for _, s := range suggestions {
s = strings.TrimSpace(s)
// 过滤空行和过长的行
if len(s) > 0 && len([]rune(s)) <= 25 {
validSuggestions = append(validSuggestions, s)
}
}
// 最多返回4个
if len(validSuggestions) > 4 {
validSuggestions = validSuggestions[:4]
}
return validSuggestions, nil
}
// GetSuggestions HTTP handler:获取追问建议
func (h *LLMChatHandler) GetSuggestions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
appID := chi.URLParam(r, "id")
if appID == "" {
response.Error(w, http.StatusBadRequest, 40001, "缺少应用ID")
return
}
var req struct {
ConversationID string `json:"conversation_id"`
Messages []map[string]string `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, 40002, "请求格式错误")
return
}
suggestions, err := h.GenerateSuggestions(ctx, req.ConversationID, appID, req.Messages)
if err != nil {
log.Warn().Err(err).Msg("generate suggestions failed")
response.Error(w, http.StatusInternalServerError, 50001, "生成追问失败")
return
}
response.JSON(w, http.StatusOK, suggestions)
}