feat(ui): Copilot 多轮对话、列定义预览确认与来源标识

This commit is contained in:
Jayway Liu
2026-11-01 10:55:04 +08:00
parent 7ff1070dc3
commit 21876d972f
6 changed files with 910 additions and 43 deletions
+266
View File
@@ -0,0 +1,266 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import fs from "node:fs";
const password =
process.env.E2E_PASSWORD ||
fs.readFileSync("var/local-access.txt", "utf8").match(/密码:(.+)/)![1];
const csv = (customerCode: string) =>
`期间,科室,学科,医生等级,医生,产品一级分类,实际收入,成本,毛利贡献,客户编码\n2025-08,测试儿保,普儿,高级,医生A,医疗服务,10000,4000,6000,${customerCode}\n2026-08,测试儿保,普儿,高级,医生A,医疗服务,5000,4000,3000,${customerCode}\n`;
// 模型请求由 route mock,登录、导入、保存定义与查询走真实后端。
async function forceAiEnabled(page: Page) {
await page.route("**/api/session", async (route) => {
const response = await route.fetch();
const json = await response.json();
const headers = Object.fromEntries(
Object.entries(response.headers()).filter(
([name]) => name.toLowerCase() !== "content-length",
),
);
await route.fulfill({
status: response.status(),
headers,
json: { ...json, ai_enabled: true },
});
});
}
async function login(page: Page) {
await page.goto("/");
await page.getByLabel("用户名", { exact: true }).fill("admin");
await page.getByLabel("密码", { exact: true }).fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.getByRole("button", { name: "导入新表格" })).toBeVisible();
}
async function uploadCsv(page: Page) {
const uploaded = page.waitForResponse(
(response) =>
response.url().endsWith("/api/datasets") &&
response.request().method() === "POST",
);
await page.getByLabel("上传财务表格").setInputFiles({
name: `assistant-e2e-${Date.now()}.csv`,
mimeType: "text/csv",
buffer: Buffer.from(csv(`E2E-${Date.now()}`)),
});
await uploaded;
await page
.getByRole("dialog", { name: "确认导入字段" })
.getByRole("button", { name: "确认并导入" })
.click();
await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }),
).toBeVisible({ timeout: 60000 });
}
async function openCopilot(page: Page) {
await page.getByRole("button", { name: "Copilot", exact: true }).click();
const copilot = page.locator(".copilot");
await copilot
.getByRole("checkbox", { name: "使用已配置的 AI 模型理解问题" })
.check();
await copilot.getByRole("checkbox", { name: /同意将本次对话/ }).check();
return copilot;
}
test("动态回答:展示模型标记与实际模型名,普通问答不改变页面", async ({
page,
}) => {
await page.setViewportSize({ width: 1512, height: 982 });
await forceAiEnabled(page);
const requests: Array<Record<string, unknown>> = [];
await page.route("**/api/datasets/*/assistant", async (route) => {
const payload = route.request().postDataJSON();
requests.push(payload);
await route.fulfill({
json: {
answer: "动态分析标记 A:本期收入 5,000,成本 4,000。",
intent: {
action: "answer",
metric: payload.context.metric,
group: payload.context.group,
filters: payload.context.filters,
},
context: payload.context,
version: "assistant-e2e",
provider: "local",
evidence: [],
kind: "answer",
source: "model",
model: "deepseek-chat-e2e",
},
});
});
await login(page);
await uploadCsv(page);
const copilot = await openCopilot(page);
await copilot.getByLabel("描述要分析的问题").fill("本期收入有哪些变化?");
await copilot.getByRole("button", { name: "发送", exact: true }).click();
await expect(
copilot.getByText("动态分析标记 A", { exact: false }),
).toBeVisible();
await expect(copilot).toContainText("来源:模型回答 · deepseek-chat-e2e");
await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }),
).toBeVisible();
await expect(page.getByRole("heading", { name: /异常调查/ })).toHaveCount(0);
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
question: "本期收入有哪些变化?",
use_model: true,
consent: true,
mode: "auto",
history: [],
});
expect(requests[0].context).toMatchObject({ filters: {} });
});
test("对话新增列:追问、预览、确认后真实保存并出现列头", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1000 });
await forceAiEnabled(page);
const requests: Array<Record<string, unknown>> = [];
await page.route("**/api/datasets/*/assistant", async (route) => {
const payload = route.request().postDataJSON();
requests.push(payload);
const first = requests.length === 1;
await route.fulfill({
json: {
answer: first ? "请补充列的定义。" : "已整理列定义。",
intent: {
action: "design_column",
metric: payload.context.metric,
group: payload.context.group,
filters: payload.context.filters,
},
context: payload.context,
version: "assistant-e2e",
provider: "local",
evidence: [],
kind: "column_design",
source: "model",
model: "deepseek-chat-e2e",
design: first
? {
complete: false,
questions: ["缺失值和零分母如何处理?"],
column: null,
}
: {
complete: true,
questions: [],
column: {
name: "收入成本差",
meaning: "实际收入减成本",
scope: "所选分组与期间",
missing: "结果不可用(#N/A);零分母不可用",
expression: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
},
},
},
});
});
await login(page);
await uploadCsv(page);
const copilot = await openCopilot(page);
await copilot.getByLabel("描述要分析的问题").fill("我想加一个新的分析列。");
await copilot.getByRole("button", { name: "发送", exact: true }).click();
const preview = copilot.getByRole("region", { name: "列定义预览" });
await expect(preview).toContainText("缺失值和零分母如何处理?");
await expect(
preview.getByRole("button", { name: "确认并添加分析列" }),
).toHaveCount(0);
await expect(
copilot.getByRole("button", { name: "退出列设计", exact: true }).first(),
).toBeVisible();
await copilot
.getByLabel("描述要分析的问题")
.fill(
"列名收入成本差,用实际收入减成本,应用当前筛选的本期和基期,缺失显示不可用。",
);
await copilot.getByRole("button", { name: "发送", exact: true }).click();
await expect(preview).toContainText("收入成本差");
await expect(preview).toContainText("实际收入减成本");
await expect(preview).toContainText("计算表达式");
await expect(preview).toContainText("(实际收入 − 成本)");
await expect(preview).toContainText("适用范围");
await expect(preview).toContainText("缺失/零分母");
expect(requests).toHaveLength(2);
expect(requests[1].mode).toBe("column_design");
expect(requests[1].question).toBe(
"列名收入成本差,用实际收入减成本,应用当前筛选的本期和基期,缺失显示不可用。",
);
expect(requests[1].history).toEqual([
{ role: "user", content: "我想加一个新的分析列。" },
{
role: "assistant",
content: expect.stringContaining("缺失值和零分母如何处理?"),
},
]);
// 保存定义与查询不 mock:走真实后端并检查返回值和表格列头。
const savedResponse = page.waitForResponse(
(response) =>
response.url().includes("/formula-definitions") &&
response.request().method() === "POST",
);
const customQuery = page.waitForResponse(
(response) =>
response.url().includes("/query") &&
response.request().method() === "POST" &&
((response.request().postDataJSON()?.custom_columns?.length as number) ||
0) > 0,
);
await preview
.getByRole("button", { name: "确认并添加分析列", exact: true })
.click();
const saved = await savedResponse;
expect(saved.ok()).toBeTruthy();
expect((await saved.json()).definition.name).toBe("收入成本差");
const query = await customQuery;
const customResult = await query.json();
// Decimal 以字符串保留精度(如 "1000.000000000000000000000000"),按数值断言。
expect(Number(customResult.summary.custom_0.current)).toBe(1000);
expect(Number(customResult.summary.custom_0.baseline)).toBe(6000);
expect(Number(customResult.summary.custom_0.delta)).toBe(-5000);
expect(Number(customResult.rows[0].values.custom_0.current)).toBe(1000);
await expect(
copilot.getByRole("status").filter({ hasText: "已添加分析列" }),
).toBeVisible();
const header = page.locator(".ag-header-cell-text", {
hasText: "收入成本差",
});
// AG Grid 可能水平虚拟化列头;每次轮询滚到最右再检查是否已渲染。
await expect
.poll(
async () => {
for (const selector of [
".ag-body-horizontal-scroll-viewport",
".ag-center-cols-viewport",
]) {
const scroller = page.locator(selector);
if (await scroller.count()) {
await scroller.evaluate((element) => {
const viewport = element as HTMLElement;
viewport.scrollLeft = viewport.scrollWidth;
});
}
}
return header.count();
},
{ timeout: 20000 },
)
.toBeGreaterThan(0);
await expect(header).toBeVisible({ timeout: 20000 });
});
+3 -2
View File
@@ -128,8 +128,9 @@ test("新增列:补充信息触发追问并重新确认后生成公式工作
);
await columns.getByRole("button", { name: "应用", exact: true }).click();
const customResult = await (await customQuery).json();
expect(customResult.summary.custom_0.current).toBe("1000");
expect(customResult.rows[0].values.custom_0.current).toBe("1000");
// Decimal 以字符串保留存储精度(如 "1000.000000000000000000000000"),按数值断言。
expect(Number(customResult.summary.custom_0.current)).toBe(1000);
expect(Number(customResult.rows[0].values.custom_0.current)).toBe(1000);
await page.getByRole("button", { name: "生成公式工作簿" }).click();
const reopened = page.getByRole("dialog", { name: "生成公式工作簿" });