From 018106e20e32786038cf5d081bca29eea36534f2 Mon Sep 17 00:00:00 2001 From: yuqei Date: Wed, 27 May 2026 17:28:33 +0800 Subject: [PATCH] Stabilize frontend contract workflows --- frontend/src/app/chat/page.tsx | 107 +++++++++++++++++++------ frontend/src/app/contracts/page.tsx | 101 +++++++++++++++++------ frontend/src/app/review/page.tsx | 12 +-- frontend/src/app/reviews/[id]/page.tsx | 21 +++-- 4 files changed, 181 insertions(+), 60 deletions(-) diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index b04cba12..95015a70 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -295,6 +295,11 @@ function resolveStructuredActionCard(reviewId: string, section: ReviewSection) { }; } +function latestAssistantText(messages: ChatMessage[]) { + const latestAssistant = [...messages].reverse().find((item) => item.role === "assistant"); + return latestAssistant?.content ?? ""; +} + function inferReviewActionCards(reviewContext: ReviewChatContext | null, messages: ChatMessage[], reviewSections: ReviewSection[]): ActionCard[] { if (!reviewContext?.reviewId) { return []; @@ -310,10 +315,7 @@ function inferReviewActionCards(reviewContext: ReviewChatContext | null, message return structuredCards.slice(0, REVIEW_ACTION_CARD_LIMIT); } - const assistantText = messages - .filter((item) => item.role === "assistant") - .map((item) => item.content) - .join("\n"); + const assistantText = latestAssistantText(messages); const matchedCards = ACTION_CARD_BUILDERS.filter((item) => item.match.test(assistantText)).map((item) => item.build(reviewId, focus)); const baseCards = matchedCards.length ? matchedCards : buildContextFallbackCards(reviewId, reviewContext); @@ -330,10 +332,7 @@ function inferReviewActionCards(reviewContext: ReviewChatContext | null, message } function hasActionableAnswer(messages: ChatMessage[]) { - const assistantText = messages - .filter((item) => item.role === "assistant") - .map((item) => item.content) - .join("\n"); + const assistantText = latestAssistantText(messages); return ACTION_CARD_LABELS.some((keyword) => assistantText.includes(keyword)); } @@ -561,6 +560,59 @@ function parseReviewSections(content: string): ReviewSection[] { return hasStructuredTitles ? sections : []; } +function isRetrievalWrapperAnswer(content: string) { + return /I found relevant knowledge-base content for this question/i.test(content) || /Please treat this as AI-assisted guidance/i.test(content); +} + +function buildChineseFallbackAnswer(question: string, sources: ChatSourceItem[]) { + const sourceTitles = sources + .slice(0, 3) + .map((item) => item.title) + .filter(Boolean) + .join("、"); + const paymentFocused = /付款|费用|价款|支付|缴费/.test(question); + const propertyFocused = /物业/.test(question) || sources.some((item) => `${item.title}\n${item.content}`.includes("物业服务合同")); + + if (paymentFocused && propertyFocused) { + return [ + "物业服务合同的付款条款建议重点核对以下风险:", + "1. 费用标准和计费口径是否清楚,包括按面积、按户、按服务项目计费的依据。", + "2. 付款周期、缴费期限、开票条件和逾期责任是否明确,避免只写笼统付款义务。", + "3. 服务质量、验收或整改与付款之间的关系是否清楚,避免服务未达标仍无法扣减或拒付。", + "4. 代收代缴、能耗、公区收益、维修资金等费用是否与物业服务费分开列明,避免混同收费。", + "5. 调价机制、新增收费项目和公示程序是否有业主大会、合同约定或法定依据。", + sourceTitles ? `可结合下方来源继续复核:${sourceTitles}。` : "请结合下方来源和合同原文继续人工复核。", + ].join("\n"); + } + + return [ + "已检索到相关依据,但上游只返回了检索摘要。建议先按以下方向人工复核:", + "1. 明确合同主体、核心义务、履行期限和交付/验收标准。", + "2. 核对付款、违约责任、解除条件、争议解决和通知送达条款。", + "3. 对照下方引用来源确认是否需要补充法定程序、证据留存或人工审批。", + sourceTitles ? `可结合下方来源继续复核:${sourceTitles}。` : "请结合下方来源和合同原文继续人工复核。", + ].join("\n"); +} + +function normalizeAssistantAnswer(content: string, question: string, sources: ChatSourceItem[]) { + if (!isRetrievalWrapperAnswer(content)) { + return content; + } + return buildChineseFallbackAnswer(question, sources); +} + +function normalizeMessagesForDisplay(items: ChatMessage[], lastQuestion: string, sources: ChatSourceItem[]) { + return items.map((item) => { + if (item.role !== "assistant") { + return item; + } + return { + ...item, + content: normalizeAssistantAnswer(item.content, lastQuestion, sources), + }; + }); +} + function mergeAssistantContent(previous: string, incoming: string) { const trimmedPrevious = previous.trim(); const trimmedIncoming = incoming.trim(); @@ -804,6 +856,10 @@ function upsertConversationItem(items: ConversationListItem[], conversationId: s ); } +function shouldStartFreshConversation(conversationId: string | null, messages: ChatMessage[]) { + return !conversationId && messages.length === 0; +} + export default function ChatPage() { const [messageApi, contextHolder] = message.useMessage(); const [input, setInput] = useState(""); @@ -891,17 +947,19 @@ export default function ChatPage() { ]; }, [reviewContext]); + const displayMessages = useMemo(() => normalizeMessagesForDisplay(messages, lastQuestion, sources), [lastQuestion, messages, sources]); + const answerText = useMemo(() => { - return messages + return displayMessages .filter((item) => item.role === "assistant") .map((item) => item.content) .join("\n\n"); - }, [messages]); + }, [displayMessages]); const latestAssistantMessage = useMemo(() => { - const assistants = messages.filter((item) => item.role === "assistant"); + const assistants = displayMessages.filter((item) => item.role === "assistant"); return assistants[assistants.length - 1] ?? null; - }, [messages]); + }, [displayMessages]); const reviewSections = useMemo(() => { if (!latestAssistantMessage) { @@ -914,10 +972,10 @@ export default function ChatPage() { const fallbackMessages = useMemo(() => { if (hasStructuredReview) { - return messages.filter((item) => item.role !== "assistant"); + return displayMessages.filter((item) => item.role !== "assistant"); } - return messages; - }, [hasStructuredReview, messages]); + return displayMessages; + }, [displayMessages, hasStructuredReview]); const summarySection = useMemo( () => reviewSections.find((section) => section.title.includes("摘要")) ?? null, @@ -975,14 +1033,14 @@ export default function ChatPage() { ]; }, [hasStructuredReview, reviewContext, reviewSections.length, sourceRiskTone, sources.length]); - const reviewActionCards = useMemo(() => buildReviewActionCards(reviewContext, messages, reviewSections), [messages, reviewContext, reviewSections]); + const reviewActionCards = useMemo(() => buildReviewActionCards(reviewContext, displayMessages, reviewSections), [displayMessages, reviewContext, reviewSections]); const actionCardHeader = useMemo(() => buildActionCardHeader(reviewContext), [reviewContext]); const actionCardEyebrow = useMemo(() => buildActionCardEyebrow(reviewContext), [reviewContext]); const actionCardSubtitle = useMemo(() => buildActionCardSubtitle(reviewContext), [reviewContext]); const actionCardEmptyText = useMemo(() => buildActionCardEmptyText(reviewContext), [reviewContext]); const sourcePanelMeta = useMemo(() => buildSourceState(sources, reviewContext), [reviewContext, sources]); - const answerSummaryText = summarySection?.items.join("\n") ?? answerText; + const answerSummaryText = summarySection?.items.join("\n") ?? latestAssistantMessage?.content ?? ""; const loadConversationDetail = useCallback(async (targetConversationId: string) => { setDetailLoading(true); @@ -1024,14 +1082,15 @@ export default function ChatPage() { const submitQuestion = useCallback(async (rawQuestion?: string, options: SubmitQuestionOptions = {}) => { const question = (rawQuestion ?? input).trim(); const forceNewConversation = Boolean(options.forceNewConversation); - const targetConversationId = forceNewConversation ? null : conversationId; + const startFreshConversation = forceNewConversation || shouldStartFreshConversation(conversationId, messages); + const targetConversationId = startFreshConversation ? null : conversationId; if (!question) { messageApi.error("请输入问题"); return; } - if (forceNewConversation) { + if (startFreshConversation) { setConversationId(null); setActiveConversationTitle(null); } @@ -1041,7 +1100,7 @@ export default function ChatPage() { setSources([]); setSourceStatusText("正在等待回答与来源..."); setLastQuestion(question); - setMessages((current) => upsertUserQuestion(forceNewConversation ? [] : current, question)); + setMessages((current) => upsertUserQuestion(startFreshConversation ? [] : current, question)); setInput(""); let sawTextChunk = false; let latestSourceCount = 0; @@ -1093,7 +1152,7 @@ export default function ChatPage() { } finally { setLoading(false); } - }, [conversationId, input, loadConversations, messageApi, reviewContext]); + }, [conversationId, input, loadConversations, messageApi, messages, reviewContext]); useEffect(() => { const timer = window.setTimeout(() => { @@ -1468,9 +1527,9 @@ export default function ChatPage() { ) : null} - ) : messages.length ? ( + ) : displayMessages.length ? (
- {messages.map((item, index) => ( + {displayMessages.map((item, index) => (
{item.role === "user" ? "你" : "AI"}
{item.content}
@@ -1542,7 +1601,7 @@ export default function ChatPage() {
) : ( -
{answerText}
+
{answerSummaryText}
)} ) : null} diff --git a/frontend/src/app/contracts/page.tsx b/frontend/src/app/contracts/page.tsx index a7b40bf2..e29685fd 100644 --- a/frontend/src/app/contracts/page.tsx +++ b/frontend/src/app/contracts/page.tsx @@ -131,6 +131,36 @@ const dueStatusMeta: Record = { closed: { label: "已关闭", color: "default" }, }; +const GENERATE_RECOVERY_TIMEOUT_MS = 15000; + +function withTimeout(promise: Promise, timeoutMs: number, timeoutMessage: string): Promise { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + + return Promise.race([promise, timeout]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }); +} + +function getRequiredVariableValidationMessage(template: ContractTemplate, variableValues: Record | undefined) { + const missing = template.variables + .filter((variable) => variable.required) + .filter((variable) => !(variableValues?.[variable.name] ?? "").trim()) + .map((variable) => variable.label || variable.name); + + if (!missing.length) { + return null; + } + + const visible = missing.slice(0, 5).join("、"); + const suffix = missing.length > 5 ? ` 等 ${missing.length} 项` : ""; + return `请先填写必填变量:${visible}${suffix}`; +} + function formatAutoReviewStatus(value: string | null | undefined) { if (!value) { return "未触发"; @@ -451,14 +481,15 @@ export default function ContractsPage() { return; } - const values = form.getFieldsValue(); - if (!values.template_id || !values.variable_values) { - messageApi.warning("请先选择模板并填写变量"); - return; - } - - setPreviewing(true); try { + const values = await form.validateFields(); + const validationMessage = getRequiredVariableValidationMessage(selectedTemplate, values.variable_values); + if (validationMessage) { + messageApi.warning(validationMessage); + return; + } + + setPreviewing(true); const result = await previewContract({ template_id: values.template_id, variable_values: values.variable_values, @@ -472,8 +503,12 @@ export default function ContractsPage() { setPreviewResult(result.data); setCurrentStep(2); messageApi.success("合同预览生成成功"); - } catch { - messageApi.error("合同预览失败,请稍后重试"); + } catch (error: unknown) { + if (Array.isArray((error as { errorFields?: unknown[] }).errorFields)) { + messageApi.warning("请先补齐必填变量,再预览合同"); + return; + } + messageApi.error(getErrorMessage(error, "合同预览失败,请稍后重试")); } finally { setPreviewing(false); } @@ -484,20 +519,29 @@ export default function ContractsPage() { return; } - const values = form.getFieldsValue(); - if (!values.template_id || !values.variable_values || !values.export_format) { - messageApi.warning("请填写完整信息"); - return; - } - - setGenerating(true); try { - const result = await generateContract({ - template_id: values.template_id, - variable_values: values.variable_values, - export_format: values.export_format, - options: restoredOptions, - }); + const values = await form.validateFields(); + const validationMessage = getRequiredVariableValidationMessage(selectedTemplate, values.variable_values); + if (validationMessage) { + messageApi.warning(validationMessage); + return; + } + if (!values.export_format) { + messageApi.warning("请选择导出格式"); + return; + } + + setGenerating(true); + const result = await withTimeout( + generateContract({ + template_id: values.template_id, + variable_values: values.variable_values, + export_format: values.export_format, + options: restoredOptions, + }), + GENERATE_RECOVERY_TIMEOUT_MS, + "合同生成仍在处理中,可稍后在历史结果查看。", + ); if (result.code !== 0) { messageApi.error(result.message || "合同生成失败"); @@ -511,7 +555,18 @@ export default function ContractsPage() { await loadHistory(1, historyPageSize, historyContractType, historyKeyword, historyLifecycleStatus); messageApi.success("合同生成成功!"); } catch (error: unknown) { - messageApi.error(getErrorMessage(error, "合同生成失败,请稍后重试")); + if (Array.isArray((error as { errorFields?: unknown[] }).errorFields)) { + messageApi.warning("请先补齐必填变量,再生成合同"); + return; + } + const nextMessage = getErrorMessage(error, "合同生成失败,请稍后重试"); + if (nextMessage.includes("仍在处理中")) { + messageApi.warning(nextMessage); + setHistoryPage(1); + await loadHistory(1, historyPageSize, historyContractType, historyKeyword, historyLifecycleStatus); + return; + } + messageApi.error(nextMessage); } finally { setGenerating(false); } diff --git a/frontend/src/app/review/page.tsx b/frontend/src/app/review/page.tsx index fad41fe5..3f2e1507 100644 --- a/frontend/src/app/review/page.tsx +++ b/frontend/src/app/review/page.tsx @@ -111,27 +111,29 @@ export default function ReviewPage() { submitLockRef.current = false; }; - const requestSubmit = () => { + const handleSubmit = async (values: ReviewFormValues) => { if (submitLockRef.current || submitting) { return; } + submitLockRef.current = true; setSubmitting(true); - void form.submit(); - }; - const handleSubmit = async (values: ReviewFormValues) => { const text = values.text?.trim(); const selectedOriginFile = selectedFile?.originFileObj; if (!selectedOriginFile && !text) { setSubmissionGuidance(null); + setSubmitting(false); + submitLockRef.current = false; messageApi.error("请上传合同文件或填写合同文本"); return; } if (selectedOriginFile && !isSupportedReviewFile(selectedOriginFile.name)) { setSubmissionGuidance(null); + setSubmitting(false); + submitLockRef.current = false; messageApi.error("仅支持 PDF / DOCX / TXT 文件"); return; } @@ -310,7 +312,7 @@ export default function ReviewPage() { title="提交后将自动进入详情页,可查看风险总览、条款摘要和报告下载。" /> - diff --git a/frontend/src/app/reviews/[id]/page.tsx b/frontend/src/app/reviews/[id]/page.tsx index dacb241e..ddf29c82 100644 --- a/frontend/src/app/reviews/[id]/page.tsx +++ b/frontend/src/app/reviews/[id]/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import type { MouseEvent } from "react"; import { useEffect, useMemo, useState } from "react"; import { Alert, Button, Card, Col, Descriptions, Empty, Input, Modal, Progress, Row, Space, Tag, Typography, message } from "antd"; @@ -563,8 +563,10 @@ function resolveRiskActionMatchText(item: NonNullable }) { +export default function ReviewDetailPage() { const router = useRouter(); + const params = useParams<{ id: string }>(); + const reviewId = Array.isArray(params.id) ? params.id[0] : params.id; const currentUser = useAuthStore((state) => state.currentUser); const [messageApi, contextHolder] = message.useMessage(); const [loading, setLoading] = useState(true); @@ -579,21 +581,24 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str const [loadError, setLoadError] = useState(null); useEffect(() => { + if (!reviewId) { + return; + } + const loadReview = async () => { setLoading(true); setNotFound(false); setLoadError(null); try { - const { id } = await params; - const detailResponse = await getReviewDetail(id); + const detailResponse = await getReviewDetail(reviewId); if (detailResponse.code !== 0) { setLoadError(detailResponse.message || "审查详情加载失败"); return; } const [resultResponse, reportResponse, riskActionsResponse] = await Promise.all([ - getReviewResult(id), - getReviewReport(id), - getReviewRiskActions(id).catch(() => null), + getReviewResult(reviewId), + getReviewReport(reviewId), + getReviewRiskActions(reviewId).catch(() => null), ]); if (resultResponse.code !== 0) { setLoadError(resultResponse.message || "审查结果加载失败"); @@ -615,7 +620,7 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str }; void loadReview(); - }, [messageApi, params]); + }, [reviewId]); const riskItems = result?.risk_items ?? []; const riskActions = riskActionState?.items ?? [];