customer-model.mjs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import assert from 'node:assert/strict';
  2. import { spawn } from 'node:child_process';
  3. import { mkdir, readFile, writeFile } from 'node:fs/promises';
  4. import { fileURLToPath } from 'node:url';
  5. import { chromium } from 'playwright';
  6. // 整项最多运行 100 秒;浏览器发送一个问候,单次工作进程共享最多三次模型尝试。
  7. const root = fileURLToPath(new URL('../../', import.meta.url));
  8. const base = (process.env.FRONTEND_URL || 'http://127.0.0.1:18763').replace(/\/$/, '');
  9. const python = fileURLToPath(new URL('../../.venv/Scripts/python.exe', import.meta.url));
  10. const reportUrl = new URL('../../.work/frontend-verification/customer-model-worker.json', import.meta.url);
  11. const started = Date.now();
  12. let worker, browser, page, runId, customerToken, prepared = false;
  13. const errors = [];
  14. const report = { maximumDurationSeconds: 100, maximumProviderAttempts: 3, requestedMessages: 1 };
  15. // 执行阶段最多 85 秒,另外保留 15 秒关闭专项账户与保存检查记录。
  16. const remaining = () => Math.max(1, 85000 - (Date.now() - started));
  17. const deadline = setTimeout(() => {
  18. worker?.kill();
  19. browser?.close();
  20. }, 85000);
  21. async function helper(args) {
  22. return new Promise((resolve, reject) => {
  23. worker = spawn(python, ['scripts/check_browser_model.py', ...args], {
  24. cwd: root, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env,
  25. });
  26. let stderr = '';
  27. worker.stdout.on('data', () => {});
  28. worker.stderr.on('data', chunk => { stderr += chunk.toString(); });
  29. worker.on('error', reject);
  30. worker.on('close', code => {
  31. worker = null;
  32. if (code === 0) resolve();
  33. else reject(new Error(`单次模型工作进程退出:${code}\n${stderr}`));
  34. });
  35. });
  36. }
  37. try {
  38. assert.ok(process.env.DEEPSEEK_API_KEY, '进程环境必须提供 DEEPSEEK_API_KEY');
  39. await helper(['--preflight', '--prepare-account', '--timeout-seconds', '10']);
  40. prepared = true;
  41. const accounts = JSON.parse(await readFile(new URL('../../.work/browser-model-account.json', import.meta.url), 'utf8'));
  42. browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || 'C:/Users/Administrator/AppData/Local/ms-playwright/chromium-1228/chrome-win64/chrome.exe' });
  43. const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
  44. page = await context.newPage();
  45. page.setDefaultTimeout(12000);
  46. page.on('pageerror', error => errors.push(error.message));
  47. const auth = await context.request.post(`${base}/openapi/v1/auth/sessions`, {
  48. data: { tenant_id: accounts.tenant_id, username: accounts.username, password: accounts.password },
  49. });
  50. assert.equal(auth.status(), 200);
  51. const preparationToken = (await auth.json()).access_token;
  52. const created = await context.request.post(`${base}/openapi/v1/conversations`, {
  53. data: { store_id: accounts.store_id },
  54. headers: { Authorization: `Bearer ${preparationToken}`, 'Idempotency-Key': crypto.randomUUID() },
  55. });
  56. assert.equal(created.status(), 201);
  57. const conversation = await created.json();
  58. assert.equal(conversation.control_owner, 'AGENT');
  59. const preparatoryLogout = await context.request.delete(`${base}/openapi/v1/auth/sessions/current`, {
  60. headers: { Authorization: `Bearer ${preparationToken}` },
  61. });
  62. assert.equal(preparatoryLogout.status(), 204);
  63. report.conversationId = conversation.conversation_id;
  64. await page.goto(`${base}/#/customer/login`);
  65. await page.getByLabel('账户', { exact: true }).fill(accounts.username);
  66. await page.getByLabel('密码', { exact: true }).fill(accounts.password);
  67. await page.getByRole('checkbox', { name: '确认进入当前商家的客户服务' }).click();
  68. const loggedIn = page.waitForResponse(response => response.url().endsWith('/openapi/v1/auth/sessions') && response.request().method() === 'POST');
  69. const selectedConversation = page.waitForResponse(response => response.request().method() === 'GET' && new URL(response.url()).pathname === `/openapi/v1/conversations/${conversation.conversation_id}`);
  70. await page.getByRole('button', { name: '登录并继续', exact: true }).click();
  71. const loginResponse = await loggedIn;
  72. assert.equal(loginResponse.status(), 200);
  73. customerToken = (await loginResponse.json()).access_token;
  74. assert.equal((await selectedConversation).status(), 200, '页面读取本次新建会话');
  75. await page.getByLabel('补充问题', { exact: true }).fill('你好,请仅回复一句简短的中文问候。');
  76. const accepted = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === `/openapi/v1/conversations/${conversation.conversation_id}/messages`);
  77. await page.getByRole('button', { name: '发送', exact: true }).click();
  78. const submission = await accepted;
  79. assert.equal(submission.status(), 201);
  80. const event = await submission.json();
  81. runId = event.payload.run_id;
  82. assert.ok(runId, '浏览器消息受理返回真实 Agent 运行');
  83. report.runId = runId;
  84. await page.getByText('助手正在查询,请稍候。', { exact: false }).waitFor();
  85. assert.equal(await page.getByRole('button', { name: '发送', exact: true }).isDisabled(), true);
  86. // 真实前端轮询必须取得本次运行的 assistant 事件,固定欢迎文字不能通过检查。
  87. const answer = page.waitForResponse(async response => {
  88. if (response.request().method() !== 'GET' || new URL(response.url()).pathname !== `/openapi/v1/conversations/${conversation.conversation_id}/messages`) return false;
  89. if (!response.ok()) return false;
  90. return (await response.json()).items.some(item => item.type === 'message.assistant' && item.payload.run_id === runId);
  91. }, { timeout: remaining() });
  92. const duration = Math.min(85, Math.floor((remaining() - 5000) / 1000));
  93. assert.ok(duration > 0, '仍有单次模型执行时间');
  94. const [, received] = await Promise.all([
  95. helper(['--run-id', runId, '--conversation-id', conversation.conversation_id, '--report', fileURLToPath(reportUrl), '--timeout-seconds', String(duration)]),
  96. answer,
  97. ]);
  98. const assistant = (await received.json()).items.find(item => item.type === 'message.assistant' && item.payload.run_id === runId);
  99. assert.equal(typeof assistant.payload.text, 'string');
  100. assert.ok(assistant.payload.text.trim());
  101. await page.locator('.message').filter({ hasText: assistant.payload.text }).filter({ hasText: 'AI 购物助手' }).waitFor();
  102. await page.getByText('助手正在查询,请稍候。', { exact: false }).waitFor({ state: 'hidden' });
  103. await page.getByLabel('补充问题', { exact: true }).fill('下一条消息保持未提交');
  104. assert.equal(await page.getByRole('button', { name: '发送', exact: true }).isEnabled(), true);
  105. const model = JSON.parse(await readFile(reportUrl, 'utf8'));
  106. assert.equal(model.run_status, 'COMPLETED');
  107. assert.ok(model.provider_attempts_started >= 1 && model.provider_attempts_started <= 3);
  108. assert.ok(model.effective_max_output_tokens <= 1024);
  109. assert.equal(model.token_entries.length, model.provider_attempts_started);
  110. assert.ok(model.model_attempts.every(item => item.status === 'RECEIVED' && item.usage.total_tokens > 0));
  111. assert.ok(model.token_entries.every(item => item.status === 'SETTLED' && item.usage.total_tokens > 0));
  112. assert.deepEqual(errors, []);
  113. report.passed = true;
  114. report.assistantAnswer = assistant.payload.text;
  115. report.model = model;
  116. report.browserErrors = errors;
  117. } finally {
  118. clearTimeout(deadline);
  119. worker?.kill();
  120. try {
  121. try {
  122. if (runId && customerToken && !report.passed) {
  123. // 浏览器关闭后仍通过真实 HTTP 取消本次运行,保留已发生用量。
  124. await fetch(`${base}/openapi/v1/agent/runs/${runId}/cancel`, {
  125. method: 'POST', headers: { Authorization: `Bearer ${customerToken}` }, signal: AbortSignal.timeout(3000),
  126. });
  127. }
  128. } finally {
  129. if (prepared) {
  130. await helper(['--cleanup-account', '--timeout-seconds', '5']);
  131. report.accountCleanup = '已停用专项账户、撤销全部会话并删除本地密码文件;实际用量与业务事件保留';
  132. }
  133. report.elapsedSeconds = (Date.now() - started) / 1000;
  134. report.browserErrors = errors;
  135. await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true });
  136. await writeFile(new URL('../../.work/frontend-verification/customer-model.json', import.meta.url), JSON.stringify(report, null, 2));
  137. }
  138. } finally {
  139. await browser?.close();
  140. }
  141. }
  142. console.log(JSON.stringify({ passed: true, providerAttempts: report.model.provider_attempts_started, maxOutputTokens: report.model.effective_max_output_tokens, generatedReplyVisible: true }));