@@ -66,6 +70,8 @@ export function BusinessCard(
}
disabled={
disabled || !Renderer || call.presentation?.effect !== "confirm"
}
diff --git a/frontend/src/ai/style.css b/frontend/src/ai/style.css
index 62db5a3..a45737d 100644
--- a/frontend/src/ai/style.css
+++ b/frontend/src/ai/style.css
@@ -41,8 +41,12 @@
height: 56px;
padding: 0 var(--space-4);
border-bottom: 1px solid var(--line);
+ background: var(--semi-color-ai-background-top);
}
.ai-header h2 {
+ display: flex;
+ align-items: center;
+ gap: 8px;
font-size: 16px;
font-weight: 500;
}
@@ -67,8 +71,8 @@
padding: var(--space-3) var(--space-4) var(--space-4);
}
.ai-message {
- margin-bottom: 24px;
overflow-wrap: anywhere;
+ min-width: 0;
}
.ai-role {
display: block;
@@ -214,9 +218,101 @@
margin-bottom: var(--space-2);
}
.ai-card-title strong {
+ display: flex;
+ align-items: center;
+ gap: 6px;
font-size: 14px;
font-weight: 500;
}
+
+/* Semi owns the controls; local constraints keep the research rail usable at 360px. */
+.research-ai-input {
+ min-width: 0;
+}
+.research-ai-editor.semi-aiChatInput {
+ padding: 12px;
+ background: var(--semi-color-ai-background-bottom);
+ border-color: rgba(var(--semi-ai-purple-2), 0.65);
+}
+.research-ai-editor:focus-within {
+ border-color: var(--semi-color-ai-purple);
+}
+.research-ai-editor .tiptap {
+ min-height: 72px;
+ max-height: 180px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ overflow-wrap: anywhere;
+}
+.research-ai-editor .semi-aiChatInput-references {
+ max-height: 100px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+.research-ai-reference {
+ max-width: 100%;
+ min-width: 0;
+}
+.research-ai-reference .semi-tag {
+ max-width: 100%;
+}
+.research-ai-reference .semi-tag-content {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.research-ai-editor .semi-aiChatInput-footer {
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-top: 12px;
+}
+.research-ai-shortcut,
+.research-ai-hint {
+ font-size: 12px;
+ line-height: 18px;
+ color: var(--semi-color-text-2);
+}
+.research-ai-hint {
+ margin-top: 8px;
+}
+.research-ai-prompts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 8px;
+}
+.research-ai-prompts .semi-button {
+ height: auto;
+ white-space: normal;
+ text-align: left;
+ max-width: 100%;
+}
+.research-ai-section {
+ min-width: 0;
+}
+.research-ai-section h4 {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 12px 0;
+ font-weight: 500;
+}
+.ai-avatar {
+ width: 28px;
+ height: 28px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ border-radius: 8px;
+ background: var(--semi-color-ai-background-top);
+}
+.ai-messages .semi-ai-chat-dialogue {
+ min-height: 0;
+}
+.ai-messages .semi-ai-chat-dialogue-inner {
+ min-width: 0;
+}
.ai-tool-card p {
margin: var(--space-2) 0;
}
diff --git a/frontend/src/ai/workspace.ts b/frontend/src/ai/workspace.ts
index d28a2fc..7ec8795 100644
--- a/frontend/src/ai/workspace.ts
+++ b/frontend/src/ai/workspace.ts
@@ -1,4 +1,62 @@
import type { PageContext, UIAction } from "./types";
+import type { ResearchReference } from "./ResearchInput";
+
+/** Only expose context already sent by the transport; never include unsaved notes. */
+export function contextReferences(context: PageContext): ResearchReference[] {
+ const refs: ResearchReference[] = [];
+ const add = (id: string, label: string, value?: string | null) => {
+ if (value) refs.push({ id, name: `${label}:${value}` });
+ };
+ const scope = context.catalog_scope;
+ if (scope)
+ add("scope", "范围", `${scope.region}/${scope.universe}/D${scope.delay}`);
+ add("dataset", "数据集", context.dataset_id);
+ add("field", "字段", context.field_id);
+ add("input", "固定输入", context.template_input_id);
+ add("version", "集合版本", context.collection_version);
+ add("asset", "研究素材", context.research_asset_id);
+ add("experiment", "实验", context.research_experiment_id);
+ add("alpha", "Alpha", context.alpha_id);
+ add("run", "研究运行", context.research_run_id);
+ add("backtest", "回测", context.backtest_run_id);
+ if (context.selected_ids?.length)
+ add("selection", "已选 Alpha", `${context.selected_ids.length} 条`);
+ return refs;
+}
+
+export function researchPrompts(context: PageContext): string[] {
+ switch (context.page) {
+ case "datasets":
+ return context.unsaved_field_selection
+ ? ["解释当前数据集的字段和适用场景"]
+ : context.template_input_id
+ ? [
+ "分析这个固定输入中的字段,提出可验证的研究假设",
+ "基于这个固定输入构建候选,并预览回测",
+ ]
+ : [
+ "解释当前数据集的字段和适用场景",
+ "如何选择字段并保存为固定研究输入?",
+ ];
+ case "templates":
+ return [
+ "解释当前模板的经济含义和变量组合",
+ "检查当前研究输入与模板是否匹配",
+ ];
+ case "variants":
+ return [
+ "分析种子 Alpha,提出结构与字段变体思路",
+ "比较结构变体与市场设置变体的适用场景",
+ ];
+ case "features":
+ return ["解释当前特征方案的处理步骤", "根据固定输入提出可验证的特征假设"];
+ default:
+ return [
+ "查询当前研究对象与已有结果",
+ "介绍从字段选择到候选回测的研究流程",
+ ];
+ }
+}
export type WorkspacePage = PageContext["page"] | "mcp-keys";
const contextLabels: Record<
diff --git a/frontend/src/pages/DatasetPage.tsx b/frontend/src/pages/DatasetPage.tsx
index a5a1034..a9590d7 100644
--- a/frontend/src/pages/DatasetPage.tsx
+++ b/frontend/src/pages/DatasetPage.tsx
@@ -14,6 +14,7 @@ import {
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
+import { IconAIFilledLevel3, IconAIEditLevel3 } from "@douyinfe/semi-icons";
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
import {
api,
@@ -1054,6 +1055,13 @@ export function DatasetPage({
{summary}
{restoreAction}
{inputAction}
+ }
+ onClick={onChat}
+ >
+ AI 助手
+
{jobStatus}
@@ -1129,7 +1137,13 @@ export function DatasetPage({
-
+ }
+ onClick={onChat}
+ >
+ AI 助手
+
@@ -1172,7 +1186,13 @@ export function DatasetPage({
>
-
+
}
+ onClick={onChat}
+ >
+ AI 助手
+
@@ -1313,7 +1333,12 @@ export function DatasetPage({
)}
{draft && (
-
)}
@@ -1330,7 +1355,7 @@ export function DatasetPage({
{(draft?.scope ?? scope).universe} · Delay{" "}
{(draft?.scope ?? scope).delay}
-
保存后可交给 Chatbox 研究,构建候选并预览回测。
+
保存后可交给 AI 研究助手,构建候选并预览回测。
{draft &&
草稿 ID:{draft.id}
}
{draft && (
@@ -1377,7 +1402,13 @@ export function DatasetPage({
{d.scope.delay}
{formatTime(d.created_at, account?.timezone)}
- researchInput(d)}>用此输入研究
+ }
+ onClick={() => researchInput(d)}
+ >
+ 用此输入研究
+
查看固定字段集合
{d.field_ids.join(", ")}
diff --git a/frontend/src/research/FeaturesPage.tsx b/frontend/src/research/FeaturesPage.tsx
index 1a13418..a93842e 100644
--- a/frontend/src/research/FeaturesPage.tsx
+++ b/frontend/src/research/FeaturesPage.tsx
@@ -8,6 +8,8 @@ import {
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
+import { ResearchInput } from "../ai/ResearchInput";
+import { IconAIEditLevel3 } from "@douyinfe/semi-icons";
import { api, post } from "../api";
import type { PageContext, UIAction } from "../ai/types";
import { TemplateEditor } from "./TemplateEditor";
@@ -210,6 +212,7 @@ export function FeaturesPage({
)}
setDetailOpen(false)}
@@ -246,14 +249,49 @@ export function FeaturesPage({
onChange={(name) => setDraft({ ...draft, name })}
/>
-
+
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
diff --git a/frontend/src/research/ResearchList.tsx b/frontend/src/research/ResearchList.tsx
index 44ad64f..6c8cb2d 100644
--- a/frontend/src/research/ResearchList.tsx
+++ b/frontend/src/research/ResearchList.tsx
@@ -42,11 +42,13 @@ export function ResearchList({
export function ResearchDetail({
title,
visible,
+ width = "60%",
onClose,
children,
}: {
title: string;
visible: boolean;
+ width?: string;
onClose: () => void;
children: ReactNode;
}) {
@@ -55,7 +57,7 @@ export function ResearchDetail({
title={title}
className="research-detail"
placement="right"
- width="60%"
+ width={width}
visible={visible}
onCancel={onClose}
closeOnEsc
diff --git a/frontend/src/research/ResearchWorkspace.tsx b/frontend/src/research/ResearchWorkspace.tsx
index eaf06fa..4485f22 100644
--- a/frontend/src/research/ResearchWorkspace.tsx
+++ b/frontend/src/research/ResearchWorkspace.tsx
@@ -9,6 +9,8 @@ import {
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
+import { ResearchInput } from "../ai/ResearchInput";
+import { IconAIEditLevel3 } from "@douyinfe/semi-icons";
import { api, post, formatTime } from "../api";
import type { PageContext, UIAction } from "../ai/types";
import { initialSettings, type SimulationSettings } from "../backtests/types";
@@ -432,6 +434,7 @@ export function ResearchWorkspace({
)}
在数据目录中保存字段选择。跨数据集分别关联输入,跨市场使用目标范围的独立输入。
-
+ {page === "variants" && method === "settings" ? (
+
+ ) : (
+
+
+
+ 本次研究假设
+
+ ({
+ id: input.id,
+ name: `${input.dataset_id} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay} · ${input.field_ids.length} 字段 · ${input.id}`,
+ })),
+ ...(page === "variants" && parent
+ ? [
+ {
+ id: "parent-alpha",
+ name: `种子 Alpha:${parent}`,
+ },
+ ]
+ : []),
+ ]}
+ disabled={!!busy}
+ canSubmit={!!inputIds.length}
+ busy={busy === "AI 生成"}
+ submitLabel="AI 生成模板"
+ sendHotKey="shift+enter"
+ onSubmit={() => void task("AI 生成", generate)}
+ prompts={
+ page === "variants"
+ ? ["保留种子的经济含义,探索不同时间窗口和字段组合"]
+ : ["研究短期价格反转与成交量之间的关系"]
+ }
+ hint={
+ inputIds.length
+ ? "基于已关联的固定输入生成,保留假设供下一轮调整。"
+ : "请先关联固定研究输入。"
+ }
+ />
+
+ )}
{page !== "variants" || method === "structure" ? (
@@ -521,13 +574,6 @@ export function ResearchWorkspace({
编辑与展开{asset && ` · v${asset.version}`}
{dirty && " · 未保存"}
- void task("AI 生成", generate)}
- >
- AI 生成模板
-
{
+ 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();
+});
diff --git a/frontend/tests/research-navigation.spec.ts b/frontend/tests/research-navigation.spec.ts
index 7a82ad8..2e47fd3 100644
--- a/frontend/tests/research-navigation.spec.ts
+++ b/frontend/tests/research-navigation.spec.ts
@@ -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([]);
});
diff --git a/frontend/tests/semi-ai.spec.ts b/frontend/tests/semi-ai.spec.ts
new file mode 100644
index 0000000..c98e654
--- /dev/null
+++ b/frontend/tests/semi-ai.spec.ts
@@ -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((resolve) => {
+ release = resolve;
+ });
+ let requestBody: Record | 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([]);
+ });
+}