Stabilize frontend contract workflows
This commit is contained in:
@@ -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() {
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : messages.length ? (
|
||||
) : displayMessages.length ? (
|
||||
<div className="chat-message-list">
|
||||
{messages.map((item, index) => (
|
||||
{displayMessages.map((item, index) => (
|
||||
<div className={`chat-message chat-message-${item.role}`} key={`${item.role}-${index}`}>
|
||||
<div className="chat-message-role">{item.role === "user" ? "你" : "AI"}</div>
|
||||
<div className="chat-message-content">{item.content}</div>
|
||||
@@ -1542,7 +1601,7 @@ export default function ChatPage() {
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chat-answer-summary">{answerText}</div>
|
||||
<div className="chat-answer-summary">{answerSummaryText}</div>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
@@ -131,6 +131,36 @@ const dueStatusMeta: Record<string, { label: string; color: string }> = {
|
||||
closed: { label: "已关闭", color: "default" },
|
||||
};
|
||||
|
||||
const GENERATE_RECOVERY_TIMEOUT_MS = 15000;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getRequiredVariableValidationMessage(template: ContractTemplate, variableValues: Record<string, string> | 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);
|
||||
}
|
||||
|
||||
@@ -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="提交后将自动进入详情页,可查看风险总览、条款摘要和报告下载。"
|
||||
/>
|
||||
|
||||
<Button block type="primary" htmlType="button" loading={submitting} disabled={submitting} onClick={requestSubmit}>
|
||||
<Button block type="primary" htmlType="submit" loading={submitting} disabled={submitting}>
|
||||
提交审查
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
@@ -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<ReviewResult["risk_items"]
|
||||
return item.evidence?.quote ?? null;
|
||||
}
|
||||
|
||||
export default function ReviewDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
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<string | null>(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 ?? [];
|
||||
|
||||
Reference in New Issue
Block a user