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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user