diff --git a/frontend/package.json b/frontend/package.json
index e0276a24..2b23e86a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,6 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
+ "qa:kb-failed-ui": "node scripts/knowledge-base-failed-document-ui-e2e.mjs",
"qa:phase1": "node scripts/phase1-closed-loop-e2e.mjs",
"qa:phase2": "node scripts/phase2-closed-loop-e2e.mjs",
"qa:phase3": "node scripts/phase3-closed-loop-e2e.mjs"
diff --git a/frontend/scripts/knowledge-base-failed-document-ui-e2e.mjs b/frontend/scripts/knowledge-base-failed-document-ui-e2e.mjs
new file mode 100644
index 00000000..eb20da8f
--- /dev/null
+++ b/frontend/scripts/knowledge-base-failed-document-ui-e2e.mjs
@@ -0,0 +1,213 @@
+import { chromium, request } from "playwright";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const stamp = new Date().toISOString().replace(/[:.]/g, "-");
+const runId = `kb-failed-ui-${Date.now()}`;
+const webBase = process.env.KB_FAILED_UI_WEB_URL || "http://127.0.0.1:3005";
+const apiBase = process.env.KB_FAILED_UI_API_URL || "http://127.0.0.1:8000/api/v1";
+const healthUrl = process.env.KB_FAILED_UI_HEALTH_URL || "http://127.0.0.1:8000/health";
+const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456";
+const artifactDir =
+ process.env.KB_FAILED_UI_ARTIFACT_DIR ||
+ path.resolve(__dirname, "..", "..", "..", "project-docs", "qa-artifacts", "kb-failed-document-ui", stamp);
+
+fs.mkdirSync(artifactDir, { recursive: true });
+
+const result = {
+ run_id: runId,
+ web_base: webBase,
+ api_base: apiBase,
+ health_url: healthUrl,
+ artifact_dir: artifactDir,
+ checks: [],
+};
+
+function apiUrl(route) {
+ return `${apiBase.replace(/\/$/, "")}${route}`;
+}
+
+function screenshotPath(name) {
+ return path.join(artifactDir, `${name}.png`);
+}
+
+function assert(condition, message, details = undefined) {
+ if (!condition) {
+ const error = new Error(message);
+ error.details = details;
+ throw error;
+ }
+}
+
+async function apiRequest(api, method, route, options = {}) {
+ const headers = {
+ ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}),
+ ...(options.headers || {}),
+ };
+ const response = await api[method](apiUrl(route), { ...options, headers });
+ const text = await response.text();
+ let body = null;
+ if (text) {
+ try {
+ body = JSON.parse(text);
+ } catch {
+ body = text;
+ }
+ }
+ return { response, body, text };
+}
+
+function expectOk(payload, label) {
+ assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, {
+ body: payload.body,
+ });
+ if (payload.body && typeof payload.body === "object" && "code" in payload.body) {
+ assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body });
+ }
+}
+
+async function login(api) {
+ const payload = await apiRequest(api, "post", "/auth/login", {
+ data: { account: "admin@example.com", password: adminPassword },
+ });
+ expectOk(payload, "admin login");
+ return payload.body.data;
+}
+
+async function main() {
+ const api = await request.newContext();
+ const browser = await chromium.launch({ headless: true });
+
+ try {
+ const frontend = await api.get(`${webBase}/login`);
+ const health = await api.get(healthUrl);
+ assert(frontend.status() === 200, `frontend /login status ${frontend.status()}`);
+ assert(health.status() === 200, `backend /health status ${health.status()}`);
+
+ const adminSession = await login(api);
+
+ const kbName = `${runId} KB`;
+ const create = await apiRequest(api, "post", "/knowledge-bases", {
+ authToken: adminSession.access_token,
+ multipart: { name: kbName, description: "KB failed document ui regression" },
+ });
+ expectOk(create, "create knowledge base");
+ const kbId = create.body.data.id;
+
+ const goodDoc = await apiRequest(api, "post", `/knowledge-bases/${kbId}/documents`, {
+ authToken: adminSession.access_token,
+ multipart: {
+ file: {
+ name: `${runId}-good.txt`,
+ mimeType: "text/plain",
+ buffer: Buffer.from("合同审查依据:付款、保密、违约责任。", "utf8"),
+ },
+ },
+ });
+ expectOk(goodDoc, "upload good kb document");
+
+ const failedDoc = await apiRequest(api, "post", `/knowledge-bases/${kbId}/documents`, {
+ authToken: adminSession.access_token,
+ multipart: {
+ file: {
+ name: `${runId}-failed.txt`,
+ mimeType: "text/plain",
+ buffer: Buffer.from(""),
+ },
+ },
+ });
+ expectOk(failedDoc, "upload failed kb document");
+ assert(failedDoc.body.data.status === "failed", "failed kb document should stay failed", failedDoc.body.data);
+
+ const context = await browser.newContext({
+ viewport: { width: 1440, height: 1100 },
+ });
+ await context.addInitScript(
+ ({ token, user }) => {
+ window.localStorage.setItem("access_token", token);
+ window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 }));
+ },
+ { token: adminSession.access_token, user: adminSession.user },
+ );
+
+ const page = await context.newPage();
+ await page.goto(`${webBase}/knowledge-bases`, { waitUntil: "domcontentloaded" });
+ await page.waitForTimeout(2200);
+ await page.getByRole("button", { name: kbName }).click();
+ await page.waitForTimeout(1200);
+ await page.screenshot({ path: screenshotPath("knowledge-base-failed-ui"), fullPage: true });
+
+ const body = await page.locator("body").innerText();
+ assert(body.includes("异常文档恢复"), "failed document recovery panel should be visible", body.slice(0, 1800));
+ assert(body.includes("重试处理"), "retry button should be visible for failed document", body.slice(-1600));
+ const disabledLinks = await page.locator("a.disabled-link[aria-disabled='true']").count();
+ assert(disabledLinks >= 2, "failed document chat actions should be disabled", { disabledLinks });
+ const failedFileNameVisible = body.includes(`${runId}-failed.txt`);
+ assert(failedFileNameVisible, "failed document filename should be visible in recovery panel", body.slice(-1800));
+
+ const summary = {
+ ...result,
+ total: 1,
+ passed: 1,
+ failed: 0,
+ checks: [
+ {
+ id: "KB-FAILED-UI-001",
+ title: "knowledge base failed document UI",
+ status: "PASS",
+ details: {
+ kb_id: kbId,
+ disabled_links: disabledLinks,
+ screenshot: "knowledge-base-failed-ui.png",
+ },
+ },
+ ],
+ };
+ fs.writeFileSync(path.join(artifactDir, "kb-failed-document-ui-summary.json"), JSON.stringify(summary, null, 2), "utf8");
+ fs.writeFileSync(
+ path.join(artifactDir, "kb-failed-document-ui-summary.md"),
+ [
+ "# KB Failed Document UI E2E",
+ "",
+ `- Run ID: \`${runId}\``,
+ `- Web: \`${webBase}\``,
+ `- API: \`${apiBase}\``,
+ "- Result: PASS",
+ "",
+ "| Check | Status |",
+ "|---|---|",
+ "| KB failed document UI | PASS |",
+ ].join("\n"),
+ "utf8",
+ );
+ console.log(JSON.stringify(summary, null, 2));
+ } finally {
+ await browser.close().catch(() => undefined);
+ await api.dispose().catch(() => undefined);
+ }
+}
+
+main().catch((error) => {
+ const summary = {
+ ...result,
+ total: 1,
+ passed: 0,
+ failed: 1,
+ checks: [
+ {
+ id: "KB-FAILED-UI-001",
+ title: "knowledge base failed document UI",
+ status: "FAIL",
+ error: error.message,
+ details: error.details || {},
+ },
+ ],
+ };
+ fs.writeFileSync(path.join(artifactDir, "kb-failed-document-ui-summary.json"), JSON.stringify(summary, null, 2), "utf8");
+ console.error(JSON.stringify(summary, null, 2));
+ process.exit(1);
+});
diff --git a/frontend/scripts/phase1-closed-loop-e2e.mjs b/frontend/scripts/phase1-closed-loop-e2e.mjs
index 80d7f92c..70bdb4ba 100644
--- a/frontend/scripts/phase1-closed-loop-e2e.mjs
+++ b/frontend/scripts/phase1-closed-loop-e2e.mjs
@@ -9,8 +9,8 @@ const __dirname = path.dirname(__filename);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const runId = `phase1-auto-${Date.now()}`;
const webBase = process.env.PHASE1_WEB_URL || "http://127.0.0.1:3004";
-const apiBase = process.env.PHASE1_API_URL || `${webBase}/api/v1`;
-const healthUrl = process.env.PHASE1_HEALTH_URL || apiBase.replace(/\/api\/v1\/?$/, "/health");
+const apiBase = process.env.PHASE1_API_URL || "http://127.0.0.1:8000/api/v1";
+const healthUrl = process.env.PHASE1_HEALTH_URL || "http://127.0.0.1:8000/health";
const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456";
const artifactDir =
process.env.PHASE1_ARTIFACT_DIR ||
diff --git a/frontend/scripts/phase2-closed-loop-e2e.mjs b/frontend/scripts/phase2-closed-loop-e2e.mjs
index 99c00e0d..5417f8b9 100644
--- a/frontend/scripts/phase2-closed-loop-e2e.mjs
+++ b/frontend/scripts/phase2-closed-loop-e2e.mjs
@@ -9,8 +9,8 @@ const __dirname = path.dirname(__filename);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const runId = `phase2-auto-${Date.now()}`;
const webBase = process.env.PHASE2_WEB_URL || "http://127.0.0.1:3005";
-const apiBase = process.env.PHASE2_API_URL || `${webBase}/api/v1`;
-const healthUrl = process.env.PHASE2_HEALTH_URL || apiBase.replace(/\/api\/v1\/?$/, "/health");
+const apiBase = process.env.PHASE2_API_URL || "http://127.0.0.1:8000/api/v1";
+const healthUrl = process.env.PHASE2_HEALTH_URL || "http://127.0.0.1:8000/health";
const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456";
const artifactDir =
process.env.PHASE2_ARTIFACT_DIR ||
diff --git a/frontend/scripts/phase3-closed-loop-e2e.mjs b/frontend/scripts/phase3-closed-loop-e2e.mjs
index fb021b81..36064f3b 100644
--- a/frontend/scripts/phase3-closed-loop-e2e.mjs
+++ b/frontend/scripts/phase3-closed-loop-e2e.mjs
@@ -9,9 +9,9 @@ const __dirname = path.dirname(__filename);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const runId = `phase3-auto-${Date.now()}`;
const webBase = process.env.PHASE3_WEB_URL || "http://127.0.0.1:3006";
-const apiBase = process.env.PHASE3_API_URL || `${webBase}/api/v1`;
-const healthUrl = process.env.PHASE3_HEALTH_URL || apiBase.replace(/\/api\/v1\/?$/, "/health");
-const readinessUrl = process.env.PHASE3_READINESS_URL || `${apiBase.replace(/\/$/, "")}/system/readiness`;
+const apiBase = process.env.PHASE3_API_URL || "http://127.0.0.1:8000/api/v1";
+const healthUrl = process.env.PHASE3_HEALTH_URL || "http://127.0.0.1:8000/health";
+const readinessUrl = process.env.PHASE3_READINESS_URL || "http://127.0.0.1:8000/api/v1/system/readiness";
const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456";
const artifactDir =
process.env.PHASE3_ARTIFACT_DIR ||
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index e81e49b3..56d24cd8 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -2709,6 +2709,24 @@ body::selection {
border: 1px solid rgba(17, 18, 23, 0.12);
}
+.disabled-link {
+ opacity: 0.58;
+ filter: grayscale(0.18);
+ box-shadow: none !important;
+}
+
+.kb-recovery-item {
+ align-items: flex-start !important;
+}
+
+.kb-recovery-item .ant-list-item {
+ align-items: flex-start;
+}
+
+.kb-recovery-item + .kb-recovery-item {
+ border-top: 1px solid rgba(148, 163, 184, 0.18);
+}
+
.landing-trust-row {
display: flex;
flex-wrap: wrap;
diff --git a/frontend/src/app/knowledge-bases/page.tsx b/frontend/src/app/knowledge-bases/page.tsx
index 1c1a81f8..2411d601 100644
--- a/frontend/src/app/knowledge-bases/page.tsx
+++ b/frontend/src/app/knowledge-bases/page.tsx
@@ -71,6 +71,48 @@ function getDocumentStatusMeta(status: string) {
return DOCUMENT_STATUS_META[status] ?? { color: "default", label: status };
}
+function isDocumentReadyForChat(document: KnowledgeBaseDocumentItem) {
+ return document.status === "indexed" && document.index_status === "completed";
+}
+
+function isDocumentFailedLike(document: KnowledgeBaseDocumentItem) {
+ return (
+ document.status === "failed" ||
+ document.parse_status === "failed" ||
+ document.index_status === "failed" ||
+ document.retryable === true ||
+ Boolean(document.failure_reason)
+ );
+}
+
+function isDocumentRetryable(document: KnowledgeBaseDocumentItem) {
+ return document.retryable === true || isDocumentFailedLike(document);
+}
+
+function getDocumentActionState(document: KnowledgeBaseDocumentItem) {
+ if (isDocumentReadyForChat(document)) {
+ return {
+ readyForChat: true,
+ disabledReason: null,
+ retryable: false,
+ };
+ }
+
+ if (isDocumentFailedLike(document)) {
+ return {
+ readyForChat: false,
+ disabledReason: document.error_message || document.failure_reason || "当前文档处理失败,请先重试或重新上传替换文件。",
+ retryable: isDocumentRetryable(document),
+ };
+ }
+
+ return {
+ readyForChat: false,
+ disabledReason: document.next_action || "当前文档尚未完成索引,暂时不能进入问答。",
+ retryable: false,
+ };
+}
+
function buildKnowledgeBaseChatHref(
knowledgeBase: KnowledgeBaseItem | null,
document: KnowledgeBaseDocumentItem | null,
@@ -116,6 +158,22 @@ export default function KnowledgeBasesPage() {
[items, selectedId],
);
+ const orderedDocuments = useMemo(() => {
+ return [...documents].sort((left, right) => {
+ const leftPriority = isDocumentFailedLike(left) ? 0 : left.status === "processing" || left.status === "pending" ? 1 : 2;
+ const rightPriority = isDocumentFailedLike(right) ? 0 : right.status === "processing" || right.status === "pending" ? 1 : 2;
+ if (leftPriority !== rightPriority) {
+ return leftPriority - rightPriority;
+ }
+ return new Date(right.created_at).getTime() - new Date(left.created_at).getTime();
+ });
+ }, [documents]);
+
+ const failedDocuments = useMemo(
+ () => orderedDocuments.filter((document) => isDocumentFailedLike(document)),
+ [orderedDocuments],
+ );
+
const loadKnowledgeBases = useCallback(async () => {
if (currentUser?.role !== "admin") {
setLoading(false);
@@ -204,8 +262,6 @@ export default function KnowledgeBasesPage() {
await loadDocuments(selectedId, true);
};
- const isDocumentReadyForChat = (document: KnowledgeBaseDocumentItem) => document.status === "indexed" && document.index_status === "completed";
-
const handleCreate = async (values: KnowledgeBaseFormValues) => {
setSubmitting(true);
try {
@@ -392,6 +448,61 @@ export default function KnowledgeBasesPage() {
+
+ {failedDocuments.length ? (
+ {
+ const actionState = getDocumentActionState(document);
+ return (
+
+
+
+ {document.file_name}
+ 失败文档
+ {formatLifecycleBadge(document.parse_status)}
+ {formatLifecycleBadge(document.index_status)}
+
+ {document.error_message || document.failure_reason || "文档处理失败"}
+ {document.status_detail || "请检查文件内容或格式后重试。"}
+ {document.next_action ? 下一步:{document.next_action} : null}
+
+ {
+ event.preventDefault();
+ messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。");
+ }}
+ >
+ 审查要点
+
+ {
+ event.preventDefault();
+ messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。");
+ }}
+ >
+ 继续问答
+
+
+
+
+
+ );
+ }}
+ />
+ ) : (
+
+ )}
+
+
}}
@@ -521,7 +632,8 @@ export default function KnowledgeBasesPage() {
key: "chat_actions",
width: 220,
render: (_value: unknown, record: KnowledgeBaseDocumentItem) => {
- const readyForChat = isDocumentReadyForChat(record);
+ const actionState = getDocumentActionState(record);
+ const readyForChat = actionState.readyForChat;
return (
{
if (!readyForChat) {
event.preventDefault();
- messageApi.warning("当前文档尚未完成索引,暂时不能进入问答。");
+ messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。");
}
}}
>
@@ -562,14 +674,14 @@ export default function KnowledgeBasesPage() {
onClick={(event) => {
if (!readyForChat) {
event.preventDefault();
- messageApi.warning("当前文档尚未完成索引,暂时不能进入问答。");
+ messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。");
}
}}
>
继续问答
- {record.retryable ? (
-