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:
@@ -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
|
||||
@@ -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 ]
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user