From 8959456eb7fc94f059eebf5040f0b31435e70490 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Tue, 23 Jun 2026 15:35:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=BF=BD=E9=97=AE?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20-=20LLM=E6=99=BA=E8=83=BD=E7=94=9F?= =?UTF-8?q?=E6=88=90=20+=20=E6=98=BE=E7=A4=BA=E4=BD=8D=E7=BD=AE=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 AuthLoader 组件中 router.push 在渲染期间调用的 React 警告 - 追问问题改为只在最新 AI 回复下方显示(修复多处显示问题) - 新增 LLM 生成追问 API:POST /api/v1/apps/{id}/suggestions - 前端调用 LLM API 生成智能追问,失败时降级到规则生成 - 登录页填入默认测试账号 kj-admin@govai.gov.cn --- apps/web/src/app/(auth)/login/page.tsx | 4 +- apps/web/src/components/app-ui/agent-ui.tsx | 2 +- apps/web/src/components/app-ui/chatbot-ui.tsx | 108 ++++++++++++++++-- apps/web/src/components/providers.tsx | 20 ++-- server/cmd/server/router.go | 1 + server/internal/handler/chat_llm.go | 97 ++++++++++++++++ 6 files changed, 211 insertions(+), 21 deletions(-) diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 89c40fd..ec8d727 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -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([]); diff --git a/apps/web/src/components/app-ui/agent-ui.tsx b/apps/web/src/components/app-ui/agent-ui.tsx index d48061c..0166282 100644 --- a/apps/web/src/components/app-ui/agent-ui.tsx +++ b/apps/web/src/components/app-ui/agent-ui.tsx @@ -140,6 +140,7 @@ interface AgentUIProps { export default function AgentUI({ app }: AgentUIProps) { const router = useRouter(); const queryClient = useQueryClient(); + const [conversationId, setConversationId] = useState(); const [messages, setMessages] = useState( 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(); const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [deleteTarget, setDeleteTarget] = useState<{ diff --git a/apps/web/src/components/app-ui/chatbot-ui.tsx b/apps/web/src/components/app-ui/chatbot-ui.tsx index 4e92ff1..52ff3bd 100644 --- a/apps/web/src/components/app-ui/chatbot-ui.tsx +++ b/apps/web/src/components/app-ui/chatbot-ui.tsx @@ -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({ )} + {/* 对话结束后的提示问题 */} + {suggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, i) => ( + + ))} +
+ )} ); @@ -98,14 +116,15 @@ export default function ChatbotUI({ app }: ChatbotUIProps) { const router = useRouter(); const queryClient = useQueryClient(); const { user } = useAuthStore(); + const [conversationId, setConversationId] = useState(); const [messages, setMessages] = useState( app.welcome_message && !conversationId ? [{ id: "welcome", role: "assistant", content: app.welcome_message }] : [], ); + const [currentSuggestions, setCurrentSuggestions] = useState([]); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); - const [conversationId, setConversationId] = useState(); const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(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) {
- {messages.map((msg) => ( - - ))} + {messages.map((msg, idx) => { + const isLastAssistant = msg.role === "assistant" && idx === messages.length - 1; + return ( + { + setInput(text); + textareaRef.current?.focus(); + }} + /> + ); + })} {messages.length <= 1 && suggestedPrompts.length > 0 && (
diff --git a/apps/web/src/components/providers.tsx b/apps/web/src/components/providers.tsx index fae12bf..3e88775 100644 --- a/apps/web/src/components/providers.tsx +++ b/apps/web/src/components/providers.tsx @@ -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 ( -
-
-
- ); - } - - if (isLoading) { + // 加载中或未认证 + if (isLoading || !isAuthenticated) { return (
diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 6fc2424..18a540f 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -132,6 +132,7 @@ func newRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *redis.Client) http.H r.Post("/generate-analysis", analysisH.GenerateReport) r.Get("/conversations", chatH.Conversations) r.Get("/conversations/{convId}/messages", chatH.Messages) + r.Post("/suggestions", chatH.GetSuggestions) r.Delete("/conversations/{convId}", chatH.DeleteConversation) r.Put("/conversations/{convId}/name", chatH.RenameConversation) r.Post("/conversations/batch-delete", chatH.BatchDeleteConversations) diff --git a/server/internal/handler/chat_llm.go b/server/internal/handler/chat_llm.go index b31d51b..5804aa9 100644 --- a/server/internal/handler/chat_llm.go +++ b/server/internal/handler/chat_llm.go @@ -1737,3 +1737,100 @@ func (h *LLMChatHandler) generateConversationName(appID, userID, convID, userMes DO UPDATE SET name = EXCLUDED.name, updated_at = now()`, 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) +}