feat: 使用 Semi AI 组件升级研究助手与研究入口

This commit is contained in:
yuxuanhui
2026-09-10 12:22:26 +08:00
parent 9334b6f651
commit d403008f9b
16 changed files with 974 additions and 174 deletions
+58 -2
View File
@@ -93,7 +93,9 @@ test("model settings, text stream, contextual query, confirmation and preserved
await page.getByRole("button", { name: "切换研究助手" }).click();
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
await chat.getByRole("button", { name: "新会话", exact: true }).click();
await expect(chat.locator(".ai-context")).toContainText("TEST0001");
await expect(chat.locator(".research-ai-reference")).toContainText(
"TEST0001",
);
const input = chat.getByRole("textbox", { name: "发送给研究助手" });
await input.fill("修改当前 Alpha 的备注");
await chat.getByRole("button", { name: "发送", exact: true }).click();
@@ -263,7 +265,7 @@ test("Lark workspace keeps pagination, account details and chat usable at every
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
await page.getByRole("button", { name: "查看个人信息", exact: true }).click();
await expect(input).toHaveValue("保留聊天草稿");
await expect(input).toHaveText("保留聊天草稿");
await chat.getByRole("button", { name: "收起研究助手" }).click();
await page.getByRole("button", { name: "配置", exact: true }).click();
await expect(
@@ -406,3 +408,57 @@ test("Markdown streams incomplete code, finishes safely and restores history", a
await expect(chat.getByRole("cell", { name: "1.2" })).toBeVisible();
expect(errors).toEqual([]);
});
test("AI composer preserves literal text, IME input and drafts while generating", async ({
page,
}) => {
await login(page);
await configure(page);
await page.getByRole("button", { name: "切换研究助手" }).click();
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
await chat.getByRole("button", { name: "新会话", exact: true }).click();
const input = chat.getByRole("textbox", { name: "发送给研究助手" });
await expect(input.locator("p").first()).toHaveAttribute(
"data-placeholder",
"描述研究问题或要执行的操作…",
);
const sent: string[] = [];
page.on("request", (request) => {
if (
request.method() === "POST" &&
/\/ai\/conversations\/[^/]+\/runs$/.test(request.url())
)
sent.push(request.postDataJSON().message);
});
await chat
.getByRole("button", { name: "查询当前研究对象与已有结果", exact: true })
.click();
await expect(input).toHaveText("查询当前研究对象与已有结果");
expect(sent).toEqual([]);
await input.fill("SLOW <字段>");
await input.dispatchEvent("keydown", {
key: "Enter",
code: "Enter",
isComposing: true,
});
expect(sent).toEqual([]);
await input.press("Shift+Enter");
await input.pressSequentially("next");
await input.press("Enter");
await expect.poll(() => sent).toEqual(["SLOW <字段>\nnext"]);
await expect(chat.getByRole("button", { name: "停止生成" })).toBeVisible();
await input.fill("下一轮 <收益> 假设");
await chat.getByRole("button", { name: "停止生成" }).click();
await expect(chat.locator(".ai-run-state")).toContainText("已停止");
await expect(input).toHaveText("下一轮 <收益> 假设");
await input.fill("a".repeat(20001));
await expect(
chat.getByRole("button", { name: "发送", exact: true }),
).toBeDisabled();
await input.press("Enter");
expect(sent).toHaveLength(1);
await input.fill("");
await expect(
chat.getByRole("button", { name: "发送", exact: true }),
).toBeDisabled();
});
+3 -1
View File
@@ -139,6 +139,8 @@ test("fixed input, AI approval, backtest and research source remain connected",
await inputSheet
.getByRole("button", { name: "用此输入研究", exact: true })
.click();
await expect(chat.locator(".ai-context")).toContainText("固定研究输入");
await expect(
chat.locator(".research-ai-reference").filter({ hasText: "固定输入" }),
).toContainText(input.id);
expect(errors).toEqual([]);
});
+173
View File
@@ -0,0 +1,173 @@
import { expect, test } from "@playwright/test";
// UI contract fixtures: no external model call or simulation is started by these tests.
const fixed = {
id: "semi-fixed-input",
dataset_id: "TEST_FIN",
field_ids: ["TEST_FIN_001"],
field_types: { TEST_FIN_001: "MATRIX" },
collection_version: "fixed-v1",
scope: {
instrument_type: "EQUITY",
region: "USA",
universe: "TOP3000",
delay: 1,
},
};
for (const mode of ["templates", "variants", "features"] as const) {
test(`${mode}: AI generation keeps its hypothesis, fixed inputs and failure draft`, async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/api/v1/research/inputs", (route) =>
route.fulfill({ json: { items: [fixed] } }),
);
await page.route(
"**/api/v1/research/assets/semi-generated/versions",
(route) => route.fulfill({ json: [{ version: 1 }] }),
);
await page.goto(`/#${mode}`);
await page
.getByLabel("密码", { exact: true })
.fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
const feature = mode === "features";
await page
.getByRole("button", {
name: feature ? "新建特征方案" : "新建",
exact: true,
})
.click();
const sheet = page.locator(".research-detail:visible");
const input = sheet.getByRole("textbox", {
name: feature ? "特征经济假设" : "本次研究假设",
exact: true,
});
const generate = sheet.getByRole("button", {
name: feature ? "AI 生成新方案" : "AI 生成模板",
exact: true,
});
await expect(generate).toBeDisabled();
await sheet
.getByRole("combobox", {
name: feature ? "特征固定输入" : "固定研究输入",
exact: true,
})
.click();
await page
.getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) })
.click();
await input.click();
if (mode === "variants")
await sheet.getByLabel("种子 Alpha", { exact: true }).fill("TEST0001");
const hypothesis = "检验 <收益> 与规模\n第二轮保留原始假设";
await input.fill(hypothesis);
await expect(
sheet.locator(".research-ai-reference").filter({ hasText: fixed.id }),
).toBeVisible();
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
let requestBody: Record<string, unknown> | undefined;
await page.route("**/api/v1/research/generate", async (route) => {
requestBody = route.request().postDataJSON();
await pending;
const template = {
name: "AI 生成验收",
description: "",
expression: "rank({field})",
category: "template",
variables: {
field: {
kind: "field",
field_type: "MATRIX",
values: ["TEST_FIN_001"],
},
},
};
await route.fulfill({
json: {
id: "semi-generated",
kind: feature ? "feature" : "template",
name: "AI 生成验收",
version: 1,
content: feature
? {
name: "AI 生成验收",
hypothesis,
input_ids: [fixed.id],
steps: [
{
name: "排名",
rationale: "降低尺度差异",
expression: "rank(TEST_FIN_001)",
},
],
template,
}
: template,
},
});
});
await input.press("Shift+Enter");
await expect
.poll(() => requestBody)
.toMatchObject({
hypothesis,
input_ids: [fixed.id],
method: feature
? "feature"
: mode === "variants"
? "structure"
: "template",
});
if (mode === "variants")
expect(requestBody?.parent_alpha_ids).toEqual(["TEST0001"]);
await expect(generate).toBeDisabled();
await expect(input).toContainText("第二轮保留原始假设");
release();
await expect(
sheet.getByRole("textbox", { name: "模板名称", exact: true }),
).toHaveValue("AI 生成验收");
await expect(generate).toBeEnabled();
await expect(input).toContainText("检验 <收益> 与规模");
await page.screenshot({ path: `../output/playwright/semi-ai-${mode}.png` });
await page.setViewportSize({ width: 390, height: 900 });
await expect(generate).toBeInViewport();
expect(await sheet.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(
true,
);
await page.screenshot({
path: `../output/playwright/semi-ai-${mode}-390.png`,
});
if (mode === "templates") {
await page.evaluate(() =>
document.body.setAttribute("theme-mode", "dark"),
);
await page.screenshot({
path: "../output/playwright/semi-ai-templates-dark.png",
});
await page.evaluate(() =>
document.body.setAttribute("theme-mode", "light"),
);
}
await page.route("**/api/v1/research/generate", (route) =>
route.fulfill({ status: 503, json: { detail: "模拟模型暂时不可用" } }),
);
await generate.click();
await expect(
sheet.getByText("模拟模型暂时不可用", { exact: true }),
).toBeVisible();
await expect(input).toContainText("第二轮保留原始假设");
await expect(generate).toBeEnabled();
await input.fill("a".repeat(10001));
await expect(generate).toBeDisabled();
await expect(
sheet.getByRole("alert").filter({ hasText: "10,000" }),
).toBeVisible();
expect(errors).toEqual([]);
});
}