PlantAI-rag-new 首次提交:文献知识工作台

- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化
- Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab
- 四档响应式(桌面/笔记本/平板/手机)、深浅主题
- 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测
- 真实凭据已脱敏,运行时配置在 backend/.env(不入 git)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jerry
2026-08-25 20:47:52 +08:00
co-authored by Claude Fable 5
commit 1e94de7464
171 changed files with 26291 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# deploy/bff_restart.sh —— 远端 BFF 重启(PM 使用,在远端执行)
# 用法:ssh 192.168.0.175 -p 26518 'bash ~/5090/teamWorkspace/zh/PlantAI-rag-new/deploy/bff_restart.sh [bind_addr]'
# 参数:可选绑定地址(默认 127.0.0.1;内网演示传 0.0.0.0)
# 要点:用 setsid 完全脱离 SSH 会话,stdout/stderr 全部落日志。
set -uo pipefail
ROOT="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new"
BIND="${1:-127.0.0.1}"
source ~/miniconda3/etc/profile.d/conda.sh
conda activate plantai-bff
# 杀旧进程([u] 正则技巧防 pkill 自匹配)
pkill -f "[u]vicorn app.main:app" 2>/dev/null
sleep 2
cd "$ROOT/backend"
# 加载运行时凭据(backend/.env,不入 git)并导出给应用进程。
# pydantic-settings 会把 .env 载入 settings 对象,但 services/neo4j.py 的
# _env() 直接读 os.environ,故需显式 set -a 导出。
set -a
[ -f .env ] && source .env
set +a
setsid python -m uvicorn app.main:app --host "$BIND" --port 8090 \
> /tmp/plantai-bff.log 2>&1 < /dev/null &
disown
sleep 5
if ss -tln | grep -q ":8090 "; then
echo "BFF UP ($BIND:8090)"
curl -s --max-time 20 http://127.0.0.1:8090/api/health | head -c 200
echo
# 生产形态:静态托管 frontend/dist(存在时 BFF 单进程服务整个前端)
curl -s --max-time 10 http://127.0.0.1:8090/ | grep -o "<title>[^<]*" || echo "(静态托管未生效,需同步 dist)"
else
echo "BFF FAIL —— 日志尾部:"
tail -15 /tmp/plantai-bff.log
exit 1
fi
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# deploy/integration_check.sh —— M2 集成检查(PM 使用,在远端运行)
# 用途:真实 BFF(8090)接真实上游后的端到端冒烟。
# 前置:BFF 已在 8090 运行(deploy/start-dev.sh),远端可直接访问 127.0.0.1。
set -uo pipefail
BFF="http://127.0.0.1:8090"
PASS=0; FAIL=0
ok() { echo " ✅ $1"; PASS=$((PASS+1)); }
bad() { echo " ❌ $1"; FAIL=$((FAIL+1)); }
check() { # $1 名称 $2 实际输出 $3 期望子串
if echo "$2" | grep -q "$3"; then ok "$1"; else bad "$1(输出: $(echo "$2" | head -c 200))"; fi
}
echo "== 1. 健康 =="
H=$(curl -s --max-time 10 "$BFF/api/health")
check "health 返回 status" "$H" '"status"'
check "qa 上游探测存在" "$H" '"qa"'
check "neo4j 探测存在" "$H" '"neo4j"'
echo "== 2. QA =="
QI=$(curl -s --max-time 10 "$BFF/api/qa/info")
check "qa info 版本" "$QI" '1.1.73'
echo "== 3. 会话 =="
S=$(curl -s -X POST "$BFF/api/sessions" -H 'Content-Type: application/json' -d '{"title":"集成冒烟"}')
SID=$(echo "$S" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[ -n "$SID" ] && ok "创建会话 ($SID)" || bad "创建会话(输出: $S)"
L=$(curl -s "$BFF/api/sessions")
check "会话列表包含新会话" "$L" "$SID"
echo "== 4. 知识图谱(Neo4j 直连)=="
KS=$(curl -s --max-time 10 "$BFF/api/kg/status")
check "kg status schema" "$KS" 'canonical'
E=$(curl -s --max-time 10 "$BFF/api/kg/entities?q=SPL&limit=5")
check "实体搜索" "$E" '"results"'
echo "== 5. 抽取任务 =="
J=$(curl -s --max-time 15 "$BFF/api/extraction/jobs")
check "任务列表" "$J" '"jobs"'
echo "== 6. QA 检索(真实上游,约 90s,可选)=="
if [ "${SKIP_QA:-0}" = "1" ]; then echo " ⏭ 跳过(SKIP_QA=1)"; else
Q=$(curl -s --max-time 200 -X POST "$BFF/api/qa/search" -H 'Content-Type: application/json' \
-d '{"question":"SPL gene family in rice","use_llm":false,"debug":false,"chain_top_k":3,"chain_hops":2,"chain_max_edges":12,"chain_max_nodes":10,"native_chain_enabled":true,"native_chain_max_hops":3,"native_chain_top_k":5}')
check "search 返回 sources" "$Q" '"sources"'
fi
[ -n "$SID" ] && curl -s -X DELETE "$BFF/api/sessions/$SID" > /dev/null && ok "清理冒烟会话" || true
echo
echo "结果:$PASS 通过 / $FAIL 失败"
[ "$FAIL" -eq 0 ]
+138
View File
@@ -0,0 +1,138 @@
// deploy/m2_e2e.js —— M2 集成验收脚本(PM 使用,在远端运行)
// 真实栈端到端:浏览器 → Vite dev(5180) → 真实 BFF(8090) → 真实上游(7920/8000/Neo4j)
// 流程:新建会话 → 问答提问 → 流水线相位推进 → 答案揭示 → 来源 tab → 图谱 tab → 刷新恢复历史
// 运行:cd ~/5090/teamWorkspace/zh/PlantAI-rag-new && node deploy/m2_e2e.js
// 注意:QA 上游 LLM 当前 402 降级,答案内容断言只验「有内容」不断言自然语言质量。
const { chromium } = require('playwright');
const BASE = 'http://127.0.0.1:5180';
const QUESTION = '水稻SPL基因家族的功能研究有哪些关键进展?';
const OUT_DIR = `${__dirname}/../artifacts/m2`;
const results = [];
function check(name, ok, extra = '') {
results.push({ name, ok });
console.log(`${ok ? ' ✅' : ' ❌'} ${name}${extra ? ' —— ' + extra : ''}`);
}
async function waitFor(fn, timeoutMs, intervalMs = 800, label = '') {
const start = Date.now();
let lastErr = null;
while (Date.now() - start < timeoutMs) {
try {
const v = await fn();
if (v) return v;
} catch (e) {
lastErr = e;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`等待超时(${timeoutMs}ms): ${label}${lastErr ? ' last=' + lastErr.message : ''}`);
}
async function stepStates(page) {
return page.$$eval('.pipeline-card .p-step', (els) =>
els.map((el) => ({
name: el.querySelector('.p-name')?.textContent?.trim() || '',
state: el.className.includes('done') ? 'done' : el.className.includes('running') ? 'running' : 'waiting',
})),
);
}
(async () => {
const fs = require('fs');
fs.mkdirSync(OUT_DIR, { recursive: true });
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
page.on('console', (m) => {
if (m.type() === 'error') errors.push('console: ' + m.text());
});
try {
// 1. 加载
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell 渲染');
check('页面加载:AppShell 渲染', true);
await page.screenshot({ path: `${OUT_DIR}/01-loaded.png` });
// 2. 新建会话
await page.click('.new-task-btn');
await waitFor(() => page.locator('.composer textarea').count(), 15000, 500, '作曲器出现');
check('新建会话:作曲器出现', true);
// 3. 提问(问答模式为默认)
await page.fill('.composer textarea', QUESTION);
await page.click('.send-btn');
await waitFor(() => page.locator('.pipeline-card').count(), 20000, 500, '流水线卡出现');
check('提问:流水线卡出现', true);
// 4. 相位推进(真实 SSE,QA 上游约 40s 完成)
const statesSeen = [];
const t0 = Date.now();
let allDone = false;
while (Date.now() - t0 < 240000) {
const st = await stepStates(page);
const key = JSON.stringify(st);
if (!statesSeen.includes(key)) statesSeen.push(key);
if (st.length && st.every((s) => s.state === 'done')) { allDone = true; break; }
await new Promise((r) => setTimeout(r, 1500));
}
check('相位推进:观察到多次状态变化', statesSeen.length >= 3, `${statesSeen.length} 个不同状态快照`);
check('相位推进:全部完成', allDone, `${Math.round((Date.now() - t0) / 1000)}s`);
await page.screenshot({ path: `${OUT_DIR}/02-pipeline.png` });
// 5. 答案揭示(complete 后揭示动画,最长 8s 加速)
await waitFor(async () => {
const txt = await page.locator('.msg.assistant .msg-body').last().innerText().catch(() => '');
return txt.length > 30;
}, 60000, 1000, '助手答案出现');
const answerText = await page.locator('.msg.assistant .msg-body').last().innerText();
check('答案渲染:有内容', answerText.length > 30, `长度 ${answerText.length}`);
check('答案渲染:含文献证据(PMID/DOI 或降级提示)', /PMID|doi|evidence|证据/i.test(answerText));
await page.screenshot({ path: `${OUT_DIR}/03-answer.png` });
// 6. 文献来源 tab(真实 sources)
await page.click('.bench-tab:has-text("文献来源")');
await waitFor(async () => page.locator('.source-row').count(), 30000, 800, '来源条目出现');
const srcCount = await page.locator('.source-row').count();
check('文献来源:来源条目渲染', srcCount >= 1, `${srcCount} 条`);
await page.screenshot({ path: `${OUT_DIR}/04-sources.png` });
// 7. 知识图谱 tab(答案图谱)
await page.click('.bench-tab:has-text("知识图谱")');
await waitFor(async () => {
const n = await page.locator('.bench-body svg, .bench-body .kg-list, .bench-body .empty-note').count();
return n > 0;
}, 15000, 800, '图谱 tab 内容');
check('知识图谱:tab 有内容(图/列表/空态之一)', true);
await page.screenshot({ path: `${OUT_DIR}/05-kg.png` });
// 8. 刷新恢复(真实 BFF SQLite 持久化)
await page.reload({ waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.session-item').count(), 15000, 500, '会话列表加载');
check('刷新:会话列表恢复(SQLite)', true);
await page.click('.session-item:has-text("水稻SPL")');
await waitFor(async () => {
const n = await page.locator('.msg.assistant').count();
return n > 0;
}, 20000, 800, '历史消息恢复');
check('刷新:助手消息历史恢复', true);
await page.screenshot({ path: `${OUT_DIR}/06-restored.png` });
// 9. 浏览器错误
const realErrors = errors.filter((e) => !e.includes('favicon'));
check('无页面级 JS 错误', realErrors.length === 0, realErrors.slice(0, 3).join(' | '));
} catch (e) {
check('流程异常中断', false, e.message);
await page.screenshot({ path: `${OUT_DIR}/99-failure.png` }).catch(() => {});
} finally {
await browser.close();
}
const passed = results.filter((r) => r.ok).length;
console.log(`\nM2 验收结果:${passed}/${results.length} 通过`);
process.exit(passed === results.length ? 0 : 1);
})();
+68
View File
@@ -0,0 +1,68 @@
// deploy/m2_probe.js —— 诊断探针(PM 使用,在远端运行)
// 目的:定位真实浏览器中 SSE progress 未推进流水线相位的原因
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const logs = [];
page.on('console', (m) => {
if (m.type() === 'error' || m.type() === 'warning') logs.push(`[${m.type()}] ${m.text().slice(0, 200)}`);
});
page.on('pageerror', (e) => logs.push('[pageerror] ' + e.message.slice(0, 200)));
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.app-shell', { timeout: 15000 });
await page.click('.new-task-btn');
await page.waitForSelector('.composer textarea', { timeout: 15000 });
// 用「检索」模式(use_llm:false,走 /api/qa/search,更快的真实链路)排除揭示动画干扰
// 但 search 无 SSE——这里仍用默认问答走 SSE,只是探针只观察 60s
await page.fill('.composer textarea', 'SPL gene family in rice');
await page.click('.send-btn');
const t0 = Date.now();
const timeline = [];
let pipelineSeen = false;
while (Date.now() - t0 < 90000) {
const snap = await page.evaluate(() => {
const card = document.querySelector('.pipeline-card');
const steps = Array.from(document.querySelectorAll('.pipeline-card .p-step'));
const msgs = Array.from(document.querySelectorAll('.msg.assistant'));
const stream = document.querySelector('.stream-text')?.textContent?.length || 0;
const body = document.querySelector('.msg.assistant .msg-body')?.textContent?.length || 0;
// 检查正在进行的 fetch 到 stream 端点
const perf = performance.getEntriesByType('resource').filter((r) => r.name.includes('chat/stream'));
return {
card: !!card,
stepCount: steps.length,
states: steps.map((s) => (s.className.includes('done') ? 'D' : s.className.includes('running') ? 'R' : 'W')).join(''),
descs: steps.map((s) => s.querySelector('.p-desc')?.textContent?.slice(0, 20)).join('|'),
msgCount: msgs.length,
streamLen: stream,
bodyLen: body,
streamReqs: perf.length,
};
});
const key = JSON.stringify(snap);
if (!timeline.length || timeline[timeline.length - 1].key !== key) {
timeline.push({ t: Math.round((Date.now() - t0) / 1000), key });
console.log(`T+${Math.round((Date.now() - t0) / 1000)}s`, JSON.stringify(snap));
}
if (snap.card) pipelineSeen = true;
if (snap.bodyLen > 30 && snap.streamLen === 0 && snap.states.includes('D')) break;
if (snap.bodyLen > 50) break; // 答案已出现,看相位是否 done
await new Promise((r) => setTimeout(r, 2000));
}
console.log('\n--- 浏览器日志(error/warning)---');
logs.slice(0, 10).forEach((l) => console.log(l));
console.log('\n--- 最终 DOM 摘要 ---');
const fin = await page.evaluate(() => {
const card = document.querySelector('.pipeline-card');
return card ? card.outerHTML.slice(0, 1200) : '无 pipeline-card';
});
console.log(fin);
await browser.close();
})();
+82
View File
@@ -0,0 +1,82 @@
// deploy/m2_probe2.js —— 精确诊断探针(PM 使用,在远端运行)
// 观察完整流式生命周期:网络请求 + SSE 帧消费 + 流水线状态 + 答案块
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const netLog = [];
page.on('request', (r) => {
if (r.url().includes('/api/')) netLog.push(`REQ ${r.method()} ${r.url()}`);
});
page.on('response', (r) => {
if (r.url().includes('/api/')) netLog.push(`RES ${r.status()} ${r.url()}`);
});
const jsErrors = [];
page.on('pageerror', (e) => jsErrors.push(e.message.slice(0, 300)));
page.on('console', (m) => {
if (m.type() === 'error') jsErrors.push('[console] ' + m.text().slice(0, 300));
});
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.app-shell', { timeout: 15000 });
await page.click('.new-task-btn');
await page.waitForSelector('.composer textarea', { timeout: 15000 });
await page.fill('.composer textarea', 'SPL gene family in rice');
await page.click('.send-btn');
const t0 = Date.now();
let lastKey = '';
let lastBlockCount = -1;
while (Date.now() - t0 < 150000) {
const snap = await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('.pipeline-card'));
const card = cards[0];
const steps = card ? Array.from(card.querySelectorAll('.p-step')) : [];
const msg = document.querySelector('.msg.assistant');
// 只统计 blocks 区域,排除流水线卡文本
const blocks = msg ? Array.from(msg.querySelectorAll('.msg-body > *')) : [];
const nonPipelineText = blocks
.filter((b) => !b.classList.contains('pipeline-card'))
.map((b) => b.textContent || '')
.join(' ');
return {
cards: cards.length,
states: steps.map((s) => (s.className.includes('done') ? 'D' : s.className.includes('running') ? 'R' : 'W')).join(''),
stepCount: steps.length,
blockCount: blocks.length,
blockTypes: blocks.map((b) => b.className || b.tagName).join(','),
nonPipelineLen: nonPipelineText.length,
streamCursor: !!document.querySelector('.stream-cursor'),
};
});
const key = `${snap.cards}|${snap.states}|${snap.blockCount}|${snap.nonPipelineLen}|${snap.streamCursor}`;
if (key !== lastKey) {
lastKey = key;
console.log(`T+${Math.round((Date.now() - t0) / 1000)}s`, JSON.stringify(snap));
}
if (snap.states === 'DDDDD' && !snap.streamCursor && snap.nonPipelineLen > 100) break;
if (snap.states === 'DDDDD' && snap.blockCount > 1 && snap.nonPipelineLen > 100) break;
if (snap.blockCount > 3 && snap.nonPipelineLen > 100 && !snap.streamCursor) break;
await new Promise((r) => setTimeout(r, 2000));
}
console.log('\n--- 网络日志 ---');
netLog.forEach((l) => console.log(l));
console.log('\n--- JS 错误 ---');
(jsErrors.length ? jsErrors : ['无']).forEach((l) => console.log(l.slice(0, 400)));
// 最终:检查 store 状态(Vue app 无法直接访问,检查 DOM 证据)
const fin = await page.evaluate(() => {
const msg = document.querySelector('.msg.assistant');
const blocks = msg ? Array.from(msg.querySelectorAll('.msg-body > *')) : [];
return {
blockClasses: blocks.map((b) => (b.className || b.tagName).slice(0, 40)),
text: (msg ? msg.textContent : '').slice(0, 400),
};
});
console.log('\n--- 最终块结构 ---');
console.log(JSON.stringify(fin, null, 2));
await browser.close();
})();
+50
View File
@@ -0,0 +1,50 @@
// deploy/m2_probe3.js —— 浏览器 fetch chunk 级探针(PM 使用,在远端运行)
// 目的:确定 progress 帧丢失发生在哪一层(Vite proxy 缓冲?fetch 读取?parser?)
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
// 在页面上下文里直接发起 fetch 并记录 chunk 到达时间线与内容
const result = await page.evaluate(async () => {
const t0 = Date.now();
const log = [];
const ctrl = new AbortController();
const timeout = setTimeout(() => ctrl.abort(), 90000);
try {
const resp = await fetch('http://127.0.0.1:5180/api/qa/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: 'SPL gene family in rice',
use_llm: true, debug: false,
chain_top_k: 3, chain_hops: 2, chain_max_edges: 12, chain_max_nodes: 10,
native_chain_enabled: true, native_chain_max_hops: 3, native_chain_top_k: 5,
}),
signal: ctrl.signal,
});
log.push(`T+${Math.round((Date.now() - t0) / 1000)}s headers: status=${resp.status} ct=${resp.headers.get('content-type')}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let nChunks = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
nChunks++;
const text = decoder.decode(value, { stream: true });
const events = (text.match(/event: \w+/g) || []).join(',');
log.push(`T+${Math.round((Date.now() - t0) / 1000)}s chunk#${nChunks} len=${value.length} [${events}] preview=${text.slice(0, 80).replace(/\n/g, '↵')}`);
}
log.push(`T+${Math.round((Date.now() - t0) / 1000)}s stream done, ${nChunks} chunks total`);
} catch (e) {
log.push(`ERROR: ${e.name}: ${e.message}`);
}
clearTimeout(timeout);
return log;
});
result.forEach((l) => console.log(l));
await browser.close();
})();
+83
View File
@@ -0,0 +1,83 @@
// deploy/m2_probe4.js —— Vue store 内部状态探针(PM 使用,在远端运行)
// 通过 __vueParentComponent 链拿到 pinia,直读 chat store 的消息状态,
// 区分「store 未更新」(handler 问题)与「store 已更新但 DOM 未渲染」(响应式问题)
const { chromium } = require('playwright');
async function dumpStore(page) {
return page.evaluate(() => {
const findRoot = (el) => {
let node = el;
while (node) {
if (node.__vue_app__) return node.__vue_app__;
node = node.__vueParentComponent?.parent || null;
}
return null;
};
const rootEl = document.querySelector('#app') || document.querySelector('.app-shell');
const app = rootEl ? findRoot(rootEl) : null;
if (!app) return { err: '未找到 vue app' };
const pinia = app.config.globalProperties.$pinia;
if (!pinia) return { err: '未找到 pinia' };
const chat = pinia._s.get('chat');
if (!chat) return { err: '未找到 chat store' };
const msgs = chat.messages.map((m) => ({
role: m.role,
status: m.status,
id: m.id,
blocks: (m.blocks || []).map((b) =>
b.type === 'pipeline'
? { type: 'pipeline', status: b.status, steps: b.steps.map((s) => `${s.phase}:${s.state}`) }
: { type: b.type, len: b.type === 'text' ? (b.html || '').length : b.type === 'stream' ? (b.text || '').length : undefined },
),
}));
return {
busy: chat.busy,
completedOnce: chat.completedOnce,
currentEvents: chat.currentEvents.map((e) => e.stage),
sessionId: chat.sessionId,
msgs,
};
});
}
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.app-shell', { timeout: 15000 });
await page.click('.new-task-btn');
await page.waitForSelector('.composer textarea', { timeout: 15000 });
await page.fill('.composer textarea', 'SPL gene family in rice');
await page.click('.send-btn');
const t0 = Date.now();
let last = '';
while (Date.now() - t0 < 150000) {
const snap = await dumpStore(page);
const msgs = snap.msgs || [];
const lastAssist = msgs.filter((m) => m.role === 'assistant').pop();
const pipe = lastAssist?.blocks?.find((b) => b.type === 'pipeline');
const key = JSON.stringify({ busy: snap.busy, done: snap.completedOnce, steps: pipe?.steps, nEv: snap.currentEvents?.length });
if (key !== last) {
last = key;
const el = Math.round((Date.now() - t0) / 1000);
console.log(`T+${el}s busy=${snap.busy} completedOnce=${snap.completedOnce} events=${snap.currentEvents?.length} 步骤=${JSON.stringify(pipe?.steps)}`);
console.log(` 事件序列: ${(snap.currentEvents || []).slice(-5).join(', ')}`);
}
if (!snap.busy && snap.completedOnce && Date.now() - t0 > 30000) break;
await new Promise((r) => setTimeout(r, 2000));
}
console.log('\n=== 最终 store 状态 ===');
console.log(JSON.stringify(await dumpStore(page), null, 2));
console.log('\n=== 最终 DOM 流水线 ===');
const dom = await page.evaluate(() => {
const card = document.querySelector('.pipeline-card');
if (!card) return '无卡片';
return card.textContent.slice(0, 300);
});
console.log(dom);
await browser.close();
})();
+75
View File
@@ -0,0 +1,75 @@
// deploy/m2_probe5.js —— PipelineCard props 与渲染链探针(PM 使用,在远端运行)
// 读组件实例 props(vnode.props.block)并比较与 pinia store 的引用/状态。
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.app-shell', { timeout: 15000 });
await page.click('.new-task-btn');
await page.waitForSelector('.composer textarea', { timeout: 15000 });
await page.fill('.composer textarea', 'SPL gene family in rice');
await page.click('.send-btn');
const t0 = Date.now();
let last = '';
while (Date.now() - t0 < 150000) {
const snap = await page.evaluate(() => {
const cardEl = document.querySelector('.pipeline-card');
if (!cardEl) return { noCard: true };
// 沿 vnode 链找组件实例
let vnode = cardEl.__vueParentComponent;
const found = [];
for (let i = 0; i < 6 && vnode; i++) {
const p = vnode.props;
if (p && p.block) {
const b = p.block;
found.push({
comp: vnode.type?.name || vnode.type?.__name || 'anon',
blockStatus: b.status,
steps: (b.steps || []).map((s) => `${s.phase}:${s.state}`).join(','),
isReactiveProxy: !!b.__v_raw || typeof b.status !== 'string' || false,
});
// 读原始 store 对象比较(raw)
const raw = b.__v_raw || b;
found[found.length - 1].rawStatus = raw.status;
found[found.length - 1].rawSteps = (raw.steps || []).map((s) => s.state).join(',');
break;
}
vnode = vnode.parent;
}
// pinia 直接读
let storeBlock = null;
const rootEl = document.querySelector('#app') || document.querySelector('.app-shell');
let n = rootEl;
let app = null;
while (n && !app) { if (n.__vue_app__) app = n.__vue_app__; n = n.__vueParentComponent?.parent || null; }
if (app) {
const pinia = app.config.globalProperties.$pinia;
const chat = pinia._s.get('chat');
const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
const sb = aMsg?.blocks?.[0];
if (sb) storeBlock = { status: sb.status, steps: sb.steps.map((s) => s.state).join(',') };
}
return { found, storeBlock, domText: cardEl.textContent.slice(0, 60) };
});
const key = JSON.stringify(snap);
if (key !== last) {
last = key;
console.log(`T+${Math.round((Date.now() - t0) / 1000)}s`, JSON.stringify(snap, null, 1));
}
// 流结束后看最终状态
const doneNow = await page.evaluate(() => {
const rootEl = document.querySelector('#app');
let n = rootEl; let app = null;
while (n && !app) { if (n.__vue_app__) app = n.__vue_app__; n = n.__vueParentComponent?.parent || null; }
if (!app) return false;
return !app.config.globalProperties.$pinia._s.get('chat').busy;
});
if (doneNow && Date.now() - t0 > 30000) break;
await new Promise((r) => setTimeout(r, 2500));
}
await browser.close();
})();
+67
View File
@@ -0,0 +1,67 @@
// deploy/m2_probe6.js —— 卡片枚举探针(PM 使用,在远端运行)
// 枚举文档中全部 .pipeline-card:props 状态 / textContent / 可见性 / 所属容器 / 挂载状态
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.app-shell', { timeout: 15000 });
await page.click('.new-task-btn');
await page.waitForSelector('.composer textarea', { timeout: 15000 });
await page.fill('.composer textarea', 'SPL gene family in rice');
await page.click('.send-btn');
// 等流结束
await page.waitForFunction(() => {
const rootEl = document.querySelector('#app');
let n = rootEl; let app = null;
while (n && !app) { if (n.__vue_app__) app = n.__vue_app__; n = n.__vueParentComponent?.parent || null; }
if (!app) return false;
const chat = app.config.globalProperties.$pinia._s.get('chat');
return chat && !chat.busy && chat.completedOnce;
}, null, { timeout: 180000 });
await new Promise((r) => setTimeout(r, 3000)); // 揭示动画收尾
const report = await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('.pipeline-card'));
return cards.map((el, i) => {
let vnode = el.__vueParentComponent;
let props = null, compName = null;
for (let d = 0; d < 6 && vnode; d++) {
if (vnode.props && vnode.props.block) {
props = { status: vnode.props.block.status, steps: vnode.props.block.steps.map((s) => `${s.phase}:${s.state}`).join(',') };
compName = vnode.type?.name || vnode.type?.__name || 'anon';
break;
}
vnode = vnode.parent;
}
const msgEl = el.closest('.msg');
const chatScroll = el.closest('.chat-scroll');
const style = getComputedStyle(el);
const inDrawer = !!el.closest('.drawer');
const inOverlay = !!el.closest('.bench-overlay');
// 每个元素与文档根的连接性 + 是否在视口
const rect = el.getBoundingClientRect();
return {
i,
props,
compName,
text: el.textContent.slice(0, 70),
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
inMsg: !!msgEl,
msgText: msgEl ? msgEl.textContent.slice(0, 30) : null,
inChatScroll: !!chatScroll,
inDrawer,
inOverlay,
isConnected: el.isConnected,
};
});
});
console.log(JSON.stringify(report, null, 2));
await browser.close();
})();
+146
View File
@@ -0,0 +1,146 @@
// deploy/m3_e2e.js —— M3 综合验收(PM 使用,在远端运行)
// 覆盖:KG 自由浏览(真实 Neo4j)、抽取任务 tab(真实 8000)、三档视口布局、主题切换
const { chromium } = require('playwright');
const BASE = 'http://127.0.0.1:5180';
const OUT_DIR = `${__dirname}/../artifacts/m3`;
const fs = require('fs');
fs.mkdirSync(OUT_DIR, { recursive: true });
const results = [];
function check(name, ok, extra = '') {
results.push({ name, ok });
console.log(`${ok ? ' ✅' : ' ❌'} ${name}${extra ? ' —— ' + extra : ''}`);
}
async function waitFor(fn, timeoutMs, intervalMs = 700, label = '') {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const v = await fn();
if (v) return v;
} catch {}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`等待超时(${timeoutMs}ms): ${label}`);
}
(async () => {
const browser = await chromium.launch();
// ============ 桌面档:KG 浏览 + 抽取 ============
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell');
// KG tab:实体搜索 + 邻域
await page.click('.bench-tab:has-text("知识图谱")');
await waitFor(() => page.locator('.bench-body input[type="text"], .bench-body input').count(), 10000, 500, 'KG 搜索框');
await page.fill('.bench-body input', 'OsSPL14');
await page.press('.bench-body input', 'Enter');
await waitFor(async () => {
const n = await page.locator('.bench-body .kg-result, .bench-body [class*="result"], .bench-body [class*="entity"]').count();
return n > 0;
}, 20000, 800, '实体搜索结果');
check('KG:实体搜索有结果(真实 Neo4j fulltext 替代)', true);
await page.screenshot({ path: `${OUT_DIR}/01-kg-search.png` });
// 邻域渲染(点击第一个结果或通过已选中实体)
const clicked = await page.evaluate(async () => {
const items = document.querySelectorAll('.bench-body [class*="result"] button, .bench-body [class*="entity"] button, .bench-body [class*="result"] [class*="item"]');
if (items[0]) { items[0].click(); return true; }
return false;
});
await waitFor(async () => {
const svg = await page.locator('.bench-body svg').count();
const nodes = await page.locator('.bench-body svg circle, .bench-body svg [class*="node"]').count();
return svg > 0 && nodes > 0;
}, 30000, 800, 'KG 图谱渲染');
check('KG:邻域图谱渲染(节点+边)', true);
await page.screenshot({ path: `${OUT_DIR}/02-kg-graph.png` });
// 抽取任务 tab
await page.click('.bench-tab:has-text("提取任务")');
await waitFor(async () => {
const n = await page.locator('.bench-body [class*="job"], .bench-body [class*="prog"]').count();
return n > 0;
}, 20000, 800, '抽取任务列表');
check('抽取:真实任务列表渲染', true);
const jobText = await page.locator('.bench-body').textContent();
check('抽取:含真实任务名或状态', /MinerU|completed|failed|running|queued|wang|Pecan/i.test(jobText));
await page.screenshot({ path: `${OUT_DIR}/03-extraction.png` });
check('桌面档无 JS 错误', errors.length === 0, errors.slice(0, 2).join('|'));
await ctx.close();
}
// ============ 平板档:抽屉布局 ============
{
const ctx = await browser.newContext({ viewport: { width: 768, height: 1024 } });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.app-shell.drawers-mode').count(), 15000, 500, 'drawers-mode');
check('平板:drawers-mode 生效(单栏)', true);
// 汉堡开左抽屉
const hamburger = await page.locator('.hamburger-btn').count();
check('平板:顶栏汉堡按钮出现', hamburger > 0);
if (hamburger > 0) {
await page.click('.hamburger-btn');
await waitFor(() => page.locator('.drawer').count(), 5000, 400, '左抽屉');
const drawerVisible = await page.locator('.drawer').first().isVisible();
check('平板:左抽屉打开', drawerVisible);
await page.screenshot({ path: `${OUT_DIR}/04-tablet-drawer.png` });
// Esc 关闭(抽屉仍挂载但移出屏幕——断言 open class 而非 isHidden)
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
const openClass = await page.locator('.drawer.left').first().evaluate((el) => el.classList.contains('open')).catch(() => false);
check('平板:Esc 关闭抽屉', !openClass);
}
await ctx.close();
}
// ============ 手机档:全屏覆盖层 + 主题 ============
{
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.app-shell.mobile').count(), 15000, 500, 'mobile 档');
check('手机:mobile 档生效', true);
const sb = await page.locator('.status-bar').isHidden().catch(() => true);
check('手机:底栏隐藏(FR-RESP-004)', sb);
const benchBtn = await page.locator('.bench-btn').count();
check('手机:工作台按钮出现', benchBtn > 0);
if (benchBtn > 0) {
await page.click('.bench-btn');
await waitFor(() => page.locator('.bench-overlay').count(), 5000, 400, '工作台覆盖层');
check('手机:工作台全屏覆盖层打开', true);
await page.screenshot({ path: `${OUT_DIR}/05-mobile-overlay.png` });
// tab 切换(PM 修复的接缝)。注意覆盖层内有两套 tab(外壳 tab 条 + BenchPanel 内部 tab),
// 用外壳 tab 条(.bench-overlay-tabs 内)点击,用最后匹配的 on 断言
await page.click('.bench-overlay-tabs .bench-tab:has-text("文献来源")');
const activeTab = await page.locator('.bench-overlay-tabs .bench-tab.on').first().textContent();
check('手机:覆盖层 tab 切换(PM 接线修复)', activeTab.includes('文献来源'), activeTab.trim());
await page.click('.bench-overlay .bench-close-btn, .bench-overlay [class*="close"]').catch(async () => {
await page.keyboard.press('Escape');
});
}
// 主题切换(浅色)
await page.click('.top-bar .icon-btn:last-child');
await page.waitForTimeout(300);
const theme = await page.evaluate(() => document.documentElement.dataset.theme);
check('主题:切换为浅色(dataset.theme)', theme === 'light');
const canvasBg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
check('主题:浅色 canvas 生效', canvasBg === 'rgb(246, 248, 252)', canvasBg);
await page.screenshot({ path: `${OUT_DIR}/06-mobile-light.png` });
await ctx.close();
}
await browser.close();
const passed = results.filter((r) => r.ok).length;
console.log(`\nM3 验收结果:${passed}/${results.length} 通过`);
process.exit(passed === results.length ? 0 : 1);
})();
+138
View File
@@ -0,0 +1,138 @@
// deploy/m5_acceptance.js —— M5 PM 最终验收脚本(在远端运行)
// 与 M2/M3 验收互补:本轮跑「生产形态静态托管」+「加固后的 Vite」双链路,
// 并在三档视口执行 PRD 验收脚本的核心交互(PM 人工走查的自动化部分)。
const { chromium } = require('playwright');
const results = [];
function check(name, ok, extra = '') {
results.push({ name, ok });
console.log(`${ok ? ' ✅' : ' ❌'} ${name}${extra ? ' —— ' + extra : ''}`);
}
async function waitFor(fn, ms, iv = 700, label = '') {
const t0 = Date.now();
while (Date.now() - t0 < ms) {
try { const v = await fn(); if (v) return v; } catch {}
await new Promise((r) => setTimeout(r, iv));
}
throw new Error(`等待超时(${ms}ms): ${label}`);
}
(async () => {
const browser = await chromium.launch();
// ============ 0. 生产形态静态托管(BFF 8090 直接服务 dist)============
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
// BFF 静态托管需 frontend/dist 存在且 BFF 重启——先探测是否已挂载
const prod = await page.goto('http://127.0.0.1:8090/', { waitUntil: 'domcontentloaded' }).then(() => true).catch(() => false);
if (prod) {
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, '生产形态 AppShell');
check('生产形态:BFF 静态托管渲染 AppShell', true);
const health = await page.evaluate(async () => (await fetch('/api/health')).status);
check('生产形态:同源 /api/health 可达', health === 200);
} else {
// dist 未同步或 BFF 未带静态挂载重启——记失败但继续
check('生产形态:BFF 静态托管', false, '8090 根路径不可达(dist 未部署?)');
}
check('生产形态无 JS 错误', errors.length === 0, errors.slice(0, 2).join('|'));
await ctx.close();
}
// ============ 1. Desktop 验收脚本(PRD 核心交互)============
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell');
// 会话列表来自真实 SQLite
const sessCount = await page.locator('.session-item').count();
check('桌面:会话列表从真实 SQLite 加载', sessCount >= 1, `${sessCount} 条`);
// 打开一个有 done 状态的会话验证历史恢复(含 markdown 渲染)
const doneItem = page.locator('.session-item').filter({ has: page.locator('.status-dot.done') }).first();
if (await doneItem.count()) {
await doneItem.click();
await waitFor(() => page.locator('.msg.assistant').count(), 15000, 500, '历史消息');
const hasMd = await page.locator('.msg.assistant .md-text strong, .msg.assistant .md-text li, .msg.assistant .md-text h1, .msg.assistant .md-text h2').count();
const hasPlain = await page.locator('.msg.assistant .md-text').count();
check('桌面:历史答案渲染(markdown 块存在)', hasPlain > 0, `md-text ${hasPlain} / 加粗列表 ${hasMd}`);
// 相位卡恢复为 done
const steps = await page.locator('.pipeline-card .p-step.done').count();
check('桌面:历史相位卡恢复 done', steps >= 4, `${steps}/5`);
}
// 引用 chip 高亮链(FR-CHAT-015 补全的验收)
const chip = page.locator('.msg.assistant .inline-sources .src-chip').first();
if (await chip.count()) {
await chip.click();
await waitFor(() => page.locator('.bench-tab.on').filter({ hasText: '文献来源' }).count(), 5000, 400, '来源 tab 切换');
await waitFor(() => page.locator('.source-row.hl-active').count(), 5000, 400, '来源高亮');
check('桌面:引用 chip → 来源 tab 高亮定位', true);
} else {
check('桌面:引用 chip → 来源 tab 高亮定位', false, '当前会话无引用 chip(可接受,需人工复核一次问答)');
}
check('桌面验收无 JS 错误', errors.length === 0, errors.slice(0, 3).join('|'));
await ctx.close();
}
// ============ 2. 完整真实问答(桌面,加固后链路)============
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell');
await page.click('.new-task-btn');
await waitFor(() => page.locator('.composer textarea').count(), 15000, 500, '作曲器');
await page.fill('.composer textarea', '拟南芥HD-ZIP转录因子的生物学功能?');
await page.click('.send-btn');
// 等流结束(真实 QA 最长 92s,终态等待上限 360s 已加固)
await waitFor(async () => {
const ok = await page.evaluate(() => {
const rootEl = document.querySelector('#app');
let n = rootEl; let app = null;
while (n && !app) { if (n.__vue_app__) app = n.__vue_app__; n = n.__vueParentComponent?.parent || null; }
if (!app) return false;
const chat = app.config.globalProperties.$pinia._s.get('chat');
return chat && !chat.busy && chat.completedOnce;
});
return ok;
}, 360000, 1500, '真实问答流结束');
await waitFor(async () => {
const n = await page.locator('.msg.assistant .md-text').count();
return n > 0;
}, 30000, 800, '最终答案出现');
const answerLen = (await page.locator('.msg.assistant .md-text').last().textContent()).length;
check('真实问答:五相位终态 + 答案渲染', answerLen > 20, `答案 ${answerLen} 字`);
const allDone = (await page.locator('.pipeline-card .p-step.done').count()) >= 4;
check('真实问答:相位卡全部 done', allDone);
const srcChip = await page.locator('.msg.assistant .inline-sources .src-chip').count();
check('真实问答:内联引用 chip 出现', srcChip > 0, `${srcChip} 个`);
check('真实问答无 JS 错误', errors.length === 0, errors.slice(0, 3).join('|'));
await ctx.close();
}
// ============ 3. 三档视口布局终检 ============
for (const [name, vp] of [['mobile', { width: 390, height: 844 }], ['tablet', { width: 768, height: 1024 }], ['desktop', { width: 1440, height: 900 }]]) {
const ctx = await browser.newContext({ viewport: vp });
const page = await ctx.newPage();
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, `${name} AppShell`);
const tier = await page.evaluate(() => document.querySelector('.app-shell').className);
check(`${name}:档位 class 正确`, tier.includes(name === 'desktop' ? 'app-shell' : name === 'tablet' ? 'drawers-mode' : 'mobile'), tier.slice(0, 60));
await ctx.close();
}
await browser.close();
const passed = results.filter((r) => r.ok).length;
console.log(`\nM5 验收结果:${passed}/${results.length} 通过`);
process.exit(passed === results.length ? 0 : 1);
})();
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# deploy/start-dev.sh —— 远端启动 BFF + vite dev(在远端 tmux 中运行)
set -euo pipefail
ROOT="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new"
echo "== 启动 BFF (127.0.0.1:8090) =="
(cd "$ROOT/backend" && source ~/miniconda3/etc/profile.d/conda.sh && conda activate plantai-bff && \
nohup uvicorn app.main:app --host 127.0.0.1 --port 8090 > /tmp/plantai-bff.log 2>&1 &)
echo "== 启动 Vite dev (127.0.0.1:5180) =="
(cd "$ROOT/frontend" && nohup npm run dev > /tmp/plantai-vite.log 2>&1 &)
sleep 3
curl -s http://127.0.0.1:8090/api/health > /dev/null && echo "BFF OK" || echo "BFF 未就绪,见 /tmp/plantai-bff.log"
curl -s http://127.0.0.1:5180 > /dev/null && echo "Vite OK" || echo "Vite 未就绪,见 /tmp/plantai-vite.log"
echo
echo "== Mac 侧访问隧道 =="
echo "ssh -N -L 5180:127.0.0.1:5180 -L 8090:127.0.0.1:8090 100.109.52.26"
echo "然后浏览器打开 http://localhost:5180"
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# deploy/sync.sh —— 本地 → 远端 rsync(PM 独占执行,agent 不自行运行)
set -euo pipefail
REMOTE_HOST="100.109.52.26"
REMOTE_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new"
LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
rsync -az --delete \
--exclude 'node_modules' --exclude '__pycache__' --exclude '.venv' \
--exclude '.venv-backend' \
--exclude 'backend/data' --exclude 'frontend/dist' --exclude '.pytest_cache' \
--exclude '.env' \
--exclude '.git' \
"$LOCAL_DIR/" "$REMOTE_HOST:$REMOTE_DIR/" 2>&1 | grep -v "remote port forwarding" || true
echo "SYNC OK → $REMOTE_HOST:$REMOTE_DIR"
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# deploy/vite_restart.sh —— 远端 Vite dev 重启(PM 使用,在远端执行)
# 用法:ssh 100.109.52.26 'bash ~/5090/teamWorkspace/zh/PlantAI-rag-new/deploy/vite_restart.sh [8099|8090]'
# 参数:可选 VITE_BFF_TARGET(默认 8090 = 真实 BFF;传 mock 用 8099)
# 要点:与 bff_restart.sh 同模式——setsid 脱离 SSH、[v]ite 正则防自匹配、日志落盘。
set -uo pipefail
ROOT="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new"
TARGET="${1:-8090}"
# 杀旧进程。注意:本脚本名含 "vite"(vite_restart.sh),pkill "[v]ite" 会自匹配杀掉本脚本!
# 用 vite 真实二进制路径做模式(脚本命令行不含 node_modules/.bin)。
pkill -f "node_modules/.bin/[v]ite" 2>/dev/null
sleep 2
cd "$ROOT/frontend"
VITE_BFF_TARGET="http://127.0.0.1:${TARGET}" \
setsid npm run dev > /tmp/plantai-vite.log 2>&1 < /dev/null &
disown
sleep 6
if ss -tln | grep -q ":5180 "; then
echo "VITE UP (127.0.0.1:5180 → ${TARGET})"
else
echo "VITE FAIL —— 日志尾部:"
tail -15 /tmp/plantai-vite.log
exit 1
fi