From d403008f9b8b2d69f379274e803ee9c4b41bf8d7 Mon Sep 17 00:00:00 2001
From: yuxuanhui
Date: Thu, 10 Sep 2026 12:22:26 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BD=BF=E7=94=A8=20Semi=20AI=20?=
=?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=8D=87=E7=BA=A7=E7=A0=94=E7=A9=B6=E5=8A=A9?=
=?UTF-8?q?=E6=89=8B=E4=B8=8E=E7=A0=94=E7=A9=B6=E5=85=A5=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../semi-ai-workspace/issues/01-implement.md | 15 ++
.scratch/semi-ai-workspace/spec.md | 9 +
frontend/src/App.tsx | 17 +-
frontend/src/ai/AssistantDialogue.tsx | 115 ++++++++++
frontend/src/ai/ChatPanel.tsx | 188 ++++++----------
frontend/src/ai/ResearchInput.tsx | 211 ++++++++++++++++++
frontend/src/ai/ToolCard.tsx | 8 +-
frontend/src/ai/style.css | 98 +++++++-
frontend/src/ai/workspace.ts | 58 +++++
frontend/src/pages/DatasetPage.tsx | 41 +++-
frontend/src/research/FeaturesPage.tsx | 69 +++---
frontend/src/research/ResearchList.tsx | 4 +-
frontend/src/research/ResearchWorkspace.tsx | 78 +++++--
frontend/tests/ai.spec.ts | 60 ++++-
frontend/tests/research-navigation.spec.ts | 4 +-
frontend/tests/semi-ai.spec.ts | 173 ++++++++++++++
16 files changed, 974 insertions(+), 174 deletions(-)
create mode 100644 .scratch/semi-ai-workspace/issues/01-implement.md
create mode 100644 .scratch/semi-ai-workspace/spec.md
create mode 100644 frontend/src/ai/AssistantDialogue.tsx
create mode 100644 frontend/src/ai/ResearchInput.tsx
create mode 100644 frontend/tests/semi-ai.spec.ts
diff --git a/.scratch/semi-ai-workspace/issues/01-implement.md b/.scratch/semi-ai-workspace/issues/01-implement.md
new file mode 100644
index 0000000..707c736
--- /dev/null
+++ b/.scratch/semi-ai-workspace/issues/01-implement.md
@@ -0,0 +1,15 @@
+# 统一 AI 输入、对话与研究入口
+Type: task
+Status: resolved
+
+按 ../spec.md 实现并验证。主代理负责代码修改与最终验证;只读子代理核对测试启动方式。
+
+## Comments
+- 2026-09-10:用户已授权本地迭代。开始前仅 AGENTS.md 有用户改动,保持不动。
+
+## Answer
+- 已完成 AIChatInput 共用输入、AIChatDialogue 消息外壳、真实上下文引用、提示预填与 Semi AI 按钮/图标/Token。数据目录、模板、变体、特征生成入口已接入。
+- 保留 SDK/后端协议、流式 Markdown、工具卡片、确认及停止流程;研究详情支持窄屏,假设长度符合后端 10,000 字符限制。
+- 验证:19 项相关 Playwright E2E 全通过(本地模拟服务);最终上下文摘要调整后 2 项链路复核通过。最终 pnpm build(含 tsc)及 git diff --check 通过。
+- 人工核看助手/生成页宽窄屏、深色截图;独立只读核验未发现业务边界问题。截图位于 output/playwright/。
+- 构建提示:第三方 lottie-web 的 eval 与主分包大小警告仍存在。未执行真实模型调用或平台模拟;未发布。
diff --git a/.scratch/semi-ai-workspace/spec.md b/.scratch/semi-ai-workspace/spec.md
new file mode 100644
index 0000000..5bd765a
--- /dev/null
+++ b/.scratch/semi-ai-workspace/spec.md
@@ -0,0 +1,9 @@
+# Semi AI 研究工作空间
+
+将现有 AI 研究助手、数据目录、模板工坊、Alpha 变体、特征工程升级为 Semi 2.103.0 AI 组件体验。保留后端协议、固定输入、业务工具卡片和确认执行语义。
+
+- 助手采用 AIChatInput / AIChatDialogue,复用流式 Markdown 和业务卡片;显示真实页面引用、按场景预填提示,保留会话与草稿行为。
+- 研究假设采用共用 AI 输入区,调用原有生成接口,保留假设和结构化编辑器。
+- AI 入口使用 colorful Button、Semi AI icons 与 AI tokens;普通业务状态仍保留状态语义。
+- 不启用未实现的文件上传、模型切换、历史消息修改或自动提交。
+- 验证类型、构建、助手/数据目录/研究页面的适用 E2E,并检查宽窄屏视觉。
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7bdf912..9350403 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -11,7 +11,11 @@ import {
Tooltip,
} from "@douyinfe/semi-ui-19";
import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN";
-import { IconSetting, IconComment, IconHistory } from "@douyinfe/semi-icons";
+import {
+ IconSetting,
+ IconAIFilledLevel3,
+ IconHistory,
+} from "@douyinfe/semi-icons";
import { QuantFlowPage } from "./research/QuantFlowPage";
import { PipelinePage } from "./research/PipelinePage";
import { FeaturesPage } from "./research/FeaturesPage";
@@ -265,7 +269,7 @@ export default function App() {
/>
}
+ icon={
}
type="tertiary"
theme="borderless"
onClick={openSettings}
/>
}
+ icon={}
+ colorful
type="tertiary"
theme={chatOpen ? "light" : "borderless"}
onClick={() => setChatOpen(!chatOpen)}
@@ -299,7 +304,7 @@ export default function App() {
}
+ icon={}
type="tertiary"
theme="borderless"
onClick={() => {
diff --git a/frontend/src/ai/AssistantDialogue.tsx b/frontend/src/ai/AssistantDialogue.tsx
new file mode 100644
index 0000000..b078169
--- /dev/null
+++ b/frontend/src/ai/AssistantDialogue.tsx
@@ -0,0 +1,115 @@
+import { lazy, Suspense } from "react";
+import { AIChatDialogue } from "@douyinfe/semi-ui-19";
+import { IconAIFilledLevel3, IconUser } from "@douyinfe/semi-icons";
+import type { Message } from "@douyinfe/semi-ui-19/lib/es/aiChatDialogue/interface";
+import type { Job } from "../types";
+import type { ChatMessage, ToolCard, UIAction } from "./types";
+import { BusinessCard } from "./ToolCard";
+
+const MessageMarkdown = lazy(() =>
+ import("./MessageMarkdown").then((module) => ({
+ default: module.MessageMarkdown,
+ })),
+);
+const roleConfig = { user: { name: "你" }, assistant: { name: "研究助手" } };
+
+/** Keep SDK parts and server-owned tool states intact inside Semi's dialogue shell. */
+export function AssistantDialogue({
+ messages,
+ streaming,
+ cards,
+ awaitingApproval,
+ onDecision,
+ onAction,
+ timezone,
+ jobs,
+}: {
+ messages: ChatMessage[];
+ streaming: boolean;
+ cards: Map;
+ awaitingApproval: boolean;
+ onDecision: (id: string, approved: boolean) => Promise;
+ onAction: (action: UIAction) => void;
+ timezone?: string;
+ jobs: Job[];
+}) {
+ const sources = new Map(messages.map((message) => [message.id, message]));
+ const chats: Message[] = messages.map((message) => ({
+ id: message.id,
+ role: message.role,
+ content: message.parts
+ .filter((part) => part.type === "text")
+ .map((part) => part.text)
+ .join("\n"),
+ status:
+ streaming &&
+ message.role === "assistant" &&
+ message.id === messages.at(-1)?.id
+ ? "in_progress"
+ : "completed",
+ }));
+ return (
+ (
+
+ {message?.role === "assistant" ? (
+
+ ) : (
+
+ )}
+
+ ),
+ // Only copying is supported; editing/retrying would mutate server-owned history.
+ renderDialogueAction: ({ message, defaultActionsObj }) =>
+ message?.content ? defaultActionsObj?.copyNode : null,
+ renderDialogueContent: ({ message }) => {
+ const source = message && sources.get(message.id);
+ if (!source) return null;
+ return (
+
+ {source.parts.map((part, index) =>
+ part.type === "text" ? (
+ source.role === "assistant" ? (
+ {part.text}
}
+ >
+
+
+ ) : (
+
+ {part.text}
+
+ )
+ ) : part.type === "data-tool" ? (
+
+ ) : null,
+ )}
+
+ );
+ },
+ }}
+ />
+ );
+}
diff --git a/frontend/src/ai/ChatPanel.tsx b/frontend/src/ai/ChatPanel.tsx
index 6803961..eab640e 100644
--- a/frontend/src/ai/ChatPanel.tsx
+++ b/frontend/src/ai/ChatPanel.tsx
@@ -1,25 +1,16 @@
import { useChat } from "@ai-sdk/react";
-import {
- lazy,
- Suspense,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-import {
- Banner,
- Button,
- Select,
- Spin,
- Tag,
- TextArea,
-} from "@douyinfe/semi-ui-19";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Banner, Button, Select, Spin, Tag } from "@douyinfe/semi-ui-19";
import { api, post } from "../api";
import type { Job } from "../types";
-import { BusinessCard } from "./ToolCard";
-import { contextLabel } from "./workspace";
+import { AssistantDialogue } from "./AssistantDialogue";
+import { ResearchInput } from "./ResearchInput";
+import {
+ IconAIFilledLevel3,
+ IconPlus,
+ IconChevronRight,
+} from "@douyinfe/semi-icons";
+import { contextLabel, contextReferences, researchPrompts } from "./workspace";
import { chatTransport } from "./transport";
import { runLabels } from "./types";
import type {
@@ -33,12 +24,6 @@ import type {
UIAction,
} from "./types";
-const MessageMarkdown = lazy(() =>
- import("./MessageMarkdown").then((module) => ({
- default: module.MessageMarkdown,
- })),
-);
-
export function ChatPanel({
open,
context,
@@ -218,8 +203,10 @@ export function ChatPanel({
useEffect(() => {
if (!open) return;
previousFocus.current = document.activeElement as HTMLElement;
- const textarea = input.current?.querySelector("textarea");
- if (textarea && !textarea.disabled) textarea.focus();
+ const editor = input.current?.querySelector(
+ '[contenteditable="true"]',
+ );
+ if (editor) editor.focus();
else
root.current
?.querySelector('button[aria-label="收起研究助手"]')
@@ -257,7 +244,16 @@ export function ChatPanel({
}
}
async function send() {
- if (!text.trim() || !conversationId || activeRun || streaming) return;
+ if (
+ !ready ||
+ loading ||
+ !text.trim() ||
+ text.length > 20000 ||
+ !conversationId ||
+ activeRun ||
+ streaming
+ )
+ return;
const message = text;
setText("");
setFailure("");
@@ -302,6 +298,17 @@ export function ChatPanel({
className="ai-chat"
id="research-assistant"
aria-label="AI 研究助手"
+ onKeyDownCapture={(event) => {
+ // Semi consumes Escape in its editor even without a suggestion popup.
+ if (
+ event.key === "Escape" &&
+ (event.target as HTMLElement).closest('[contenteditable="true"]')
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ onClose();
+ }
+ }}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
@@ -310,7 +317,7 @@ export function ChatPanel({
if (event.key === "Tab" && window.innerWidth < 1440) {
const elements = [
...(root.current?.querySelectorAll(
- 'button:not(:disabled), textarea:not(:disabled), input:not(:disabled), [tabindex="0"]',
+ 'button:not(:disabled), [contenteditable="true"], input:not(:disabled), [tabindex="0"]',
) ?? []),
].filter((el) => el.offsetParent !== null);
if (event.shiftKey && document.activeElement === elements[0]) {
@@ -350,10 +357,14 @@ export function ChatPanel({
}}
/>
- AI 研究助手
+
+
+ AI 研究助手
+
}
theme="borderless"
onClick={onClose}
>
@@ -376,7 +387,8 @@ export function ChatPanel({
disabled={loading}
/>
}
onClick={() => void createConversation()}
disabled={loading}
>
@@ -408,7 +420,8 @@ export function ChatPanel({
{!conversationId && (
}
onClick={() => void createConversation()}
disabled={loading}
>
@@ -418,48 +431,16 @@ export function ChatPanel({
修改和任务操作会先展示预览,确认后执行。
)}
- {chat.messages.map((message) => (
-
-
- {message.role === "user" ? "你" : "研究助手"}
-
- {message.parts.map((part, index) =>
- part.type === "text" ? (
- message.role === "assistant" ? (
- {part.text}}
- >
-
-
- ) : (
-
- {part.text}
-
- )
- ) : part.type === "data-tool" ? (
-
- ) : null,
- )}
-
- ))}
+
{lastRun && (
@@ -483,51 +464,28 @@ export function ChatPanel({
);
diff --git a/frontend/src/ai/ResearchInput.tsx b/frontend/src/ai/ResearchInput.tsx
new file mode 100644
index 0000000..0884ee7
--- /dev/null
+++ b/frontend/src/ai/ResearchInput.tsx
@@ -0,0 +1,211 @@
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { AIChatInput, Button, Tag } from "@douyinfe/semi-ui-19";
+import {
+ IconAIEditLevel3,
+ IconAIEditLevel1,
+ IconAIFilledLevel3,
+ IconStop,
+} from "@douyinfe/semi-icons";
+
+export type ResearchReference = { id: string; name: string };
+type Editor = ReturnType;
+
+// JSON keeps expressions and user text literal instead of interpreting them as HTML.
+function textDocument(value: string) {
+ return {
+ type: "doc",
+ content: value.split("\n").map((text) => ({
+ type: "paragraph",
+ ...(text ? { content: [{ type: "text", text }] } : {}),
+ })),
+ };
+}
+function editorText(editor?: Editor) {
+ return editor?.getText({ blockSeparator: "\n" }).replace(/\u200b/g, "") ?? "";
+}
+
+/** Plain-text boundary for Semi's editor; references describe existing business context. */
+export function ResearchInput({
+ value,
+ onChange,
+ label,
+ placeholder,
+ references = [],
+ disabled = false,
+ canSubmit = true,
+ busy = false,
+ onSubmit,
+ onStop,
+ submitLabel = "发送",
+ hint,
+ prompts = [],
+ sendHotKey = "enter",
+ maxLength = 20000,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ label: string;
+ placeholder: string;
+ references?: ResearchReference[];
+ disabled?: boolean;
+ canSubmit?: boolean;
+ busy?: boolean;
+ onSubmit: () => void;
+ onStop?: () => void;
+ submitLabel?: string;
+ hint?: ReactNode;
+ prompts?: string[];
+ sendHotKey?: "enter" | "shift+enter";
+ maxLength?: number;
+}) {
+ const input = useRef(null);
+ const [editor, setEditor] = useState();
+ const initialContent = useRef(textDocument(value));
+ const currentPlaceholder = useRef(placeholder);
+ currentPlaceholder.current = placeholder;
+ // Tiptap initializes its placeholder extension once; read current availability lazily.
+ const editorPlaceholder = useCallback(() => currentPlaceholder.current, []);
+ const tooLong = value.length > maxLength;
+ const allowed = !disabled && !busy && canSubmit && !!value.trim() && !tooLong;
+ useEffect(() => {
+ // Semi exposes its editor after its child mounts, including under StrictMode.
+ let frame: number;
+ const connect = () => {
+ const next = input.current?.getEditor();
+ if (next && !next.isDestroyed) setEditor(next);
+ else frame = requestAnimationFrame(connect);
+ };
+ frame = requestAnimationFrame(connect);
+ return () => cancelAnimationFrame(frame);
+ }, []);
+ useEffect(() => {
+ if (!editor || editor.isDestroyed) return;
+ editor.setOptions({
+ editorProps: {
+ ...editor.options.editorProps,
+ attributes: {
+ role: "textbox",
+ "aria-label": label,
+ "aria-multiline": "true",
+ "aria-disabled": String(disabled),
+ "aria-invalid": String(tooLong),
+ },
+ },
+ });
+ editor.setEditable(!disabled, false);
+ }, [editor, label, disabled, tooLong]);
+ useEffect(() => {
+ if (editor && !editor.isDestroyed && editorText(editor) !== value)
+ editor.commands.setContent(textDocument(value));
+ }, [editor, value]);
+ const submit = () => {
+ if (allowed) onSubmit();
+ };
+ return (
+ {
+ if (event.key === "Enter" && event.nativeEvent.isComposing)
+ event.stopPropagation();
+ }}
+ >
+
({
+ ...reference,
+ type: "text",
+ }))}
+ renderReference={(reference) => (
+
+
+ }
+ >
+ {reference.name}
+
+
+ )}
+ onContentChange={() => onChange(editorText(input.current?.getEditor()))}
+ onMessageSend={submit}
+ onStopGenerate={onStop}
+ renderConfigureArea={() => (
+
+ {sendHotKey === "enter"
+ ? "Enter 发送 · Shift + Enter 换行"
+ : "Shift + Enter 生成 · Enter 换行"}
+
+ )}
+ renderActionArea={() =>
+ busy && onStop ? (
+ }
+ onClick={onStop}
+ >
+ 停止生成
+
+ ) : (
+ }
+ disabled={!allowed}
+ loading={busy}
+ onClick={submit}
+ >
+ {submitLabel}
+
+ )
+ }
+ />
+ {!value && !busy && prompts.length > 0 && (
+
+ {prompts.map((prompt) => (
+ }
+ disabled={disabled || busy}
+ onClick={() => {
+ onChange(prompt);
+ input.current?.focusEditor("end");
+ }}
+ >
+ {prompt}
+
+ ))}
+
+ )}
+ {tooLong && (
+
+ 内容不能超过 {maxLength.toLocaleString("en-US")}{" "}
+ 字符,请精简后再发送。
+
+ )}
+ {hint && {hint}
}
+
+ );
+}
diff --git a/frontend/src/ai/ToolCard.tsx b/frontend/src/ai/ToolCard.tsx
index 4772353..fa0e56a 100644
--- a/frontend/src/ai/ToolCard.tsx
+++ b/frontend/src/ai/ToolCard.tsx
@@ -1,3 +1,4 @@
+import { IconAIFilledLevel3, IconTick } from "@douyinfe/semi-icons";
import type { ComponentType } from "react";
import { Button, Tag } from "@douyinfe/semi-ui-19";
import { ResearchToolCard } from "../research/ResearchToolCard";
@@ -40,7 +41,10 @@ export function BusinessCard(
return (
-
{call.presentation?.label ?? call.name}
+
+
+ {call.presentation?.label ?? call.name}
+
@@ -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([]);
+ });
+}