feat: 使用 Semi AI 组件升级研究助手与研究入口
This commit is contained in:
@@ -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 与主分包大小警告仍存在。未执行真实模型调用或平台模拟;未发布。
|
||||||
@@ -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,并检查宽窄屏视觉。
|
||||||
+11
-6
@@ -11,7 +11,11 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} from "@douyinfe/semi-ui-19";
|
||||||
import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN";
|
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 { QuantFlowPage } from "./research/QuantFlowPage";
|
||||||
import { PipelinePage } from "./research/PipelinePage";
|
import { PipelinePage } from "./research/PipelinePage";
|
||||||
import { FeaturesPage } from "./research/FeaturesPage";
|
import { FeaturesPage } from "./research/FeaturesPage";
|
||||||
@@ -265,7 +269,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="workspace-tools"
|
className="workspace-tools"
|
||||||
hidden={settingsOpen}
|
hidden={settingsOpen || (chatOpen && viewport < 1440)}
|
||||||
inert={
|
inert={
|
||||||
(chatOpen && viewport < 1440) || catalogModal || settingsOpen
|
(chatOpen && viewport < 1440) || catalogModal || settingsOpen
|
||||||
}
|
}
|
||||||
@@ -275,21 +279,22 @@ export default function App() {
|
|||||||
<Tooltip content="配置" visible={settingsOpen ? false : undefined}>
|
<Tooltip content="配置" visible={settingsOpen ? false : undefined}>
|
||||||
<Button
|
<Button
|
||||||
aria-label="配置"
|
aria-label="配置"
|
||||||
icon={<IconSetting />}
|
icon={<IconSetting aria-hidden="true" />}
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
onClick={openSettings}
|
onClick={openSettings}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content="Bot · 研究助手"
|
content="AI 研究助手"
|
||||||
visible={chatOpen ? false : undefined}
|
visible={chatOpen ? false : undefined}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
aria-label="切换研究助手"
|
aria-label="切换研究助手"
|
||||||
aria-expanded={chatOpen}
|
aria-expanded={chatOpen}
|
||||||
aria-controls="research-assistant"
|
aria-controls="research-assistant"
|
||||||
icon={<IconComment />}
|
icon={<IconAIFilledLevel3 aria-hidden="true" />}
|
||||||
|
colorful
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
theme={chatOpen ? "light" : "borderless"}
|
theme={chatOpen ? "light" : "borderless"}
|
||||||
onClick={() => setChatOpen(!chatOpen)}
|
onClick={() => setChatOpen(!chatOpen)}
|
||||||
@@ -299,7 +304,7 @@ export default function App() {
|
|||||||
<Badge count={pending || undefined}>
|
<Badge count={pending || undefined}>
|
||||||
<Button
|
<Button
|
||||||
aria-label="同步任务"
|
aria-label="同步任务"
|
||||||
icon={<IconHistory />}
|
icon={<IconHistory aria-hidden="true" />}
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -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<string, ToolCard>;
|
||||||
|
awaitingApproval: boolean;
|
||||||
|
onDecision: (id: string, approved: boolean) => Promise<void>;
|
||||||
|
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 (
|
||||||
|
<AIChatDialogue
|
||||||
|
chats={chats}
|
||||||
|
roleConfig={roleConfig}
|
||||||
|
align="leftAlign"
|
||||||
|
mode="userBubble"
|
||||||
|
showReset={false}
|
||||||
|
dialogueRenderConfig={{
|
||||||
|
renderDialogueAvatar: ({ message }) => (
|
||||||
|
<span className="ai-avatar">
|
||||||
|
{message?.role === "assistant" ? (
|
||||||
|
<IconAIFilledLevel3 size="large" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<IconUser aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
// 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 (
|
||||||
|
<div className={`ai-message ${source.role}`}>
|
||||||
|
{source.parts.map((part, index) =>
|
||||||
|
part.type === "text" ? (
|
||||||
|
source.role === "assistant" ? (
|
||||||
|
<Suspense
|
||||||
|
key={index}
|
||||||
|
fallback={<div className="ai-text">{part.text}</div>}
|
||||||
|
>
|
||||||
|
<MessageMarkdown
|
||||||
|
content={part.text}
|
||||||
|
final={
|
||||||
|
!streaming ||
|
||||||
|
source.id !== messages.at(-1)?.id ||
|
||||||
|
part.state === "done"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<div className="ai-text" key={index}>
|
||||||
|
{part.text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : part.type === "data-tool" ? (
|
||||||
|
<BusinessCard
|
||||||
|
key={part.id ?? index}
|
||||||
|
call={cards.get(part.data.id) ?? part.data}
|
||||||
|
onDecision={onDecision}
|
||||||
|
disabled={streaming || !awaitingApproval}
|
||||||
|
onAction={onAction}
|
||||||
|
timezone={timezone}
|
||||||
|
jobs={jobs}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+67
-109
@@ -1,25 +1,16 @@
|
|||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import {
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
lazy,
|
import { Banner, Button, Select, Spin, Tag } from "@douyinfe/semi-ui-19";
|
||||||
Suspense,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
Banner,
|
|
||||||
Button,
|
|
||||||
Select,
|
|
||||||
Spin,
|
|
||||||
Tag,
|
|
||||||
TextArea,
|
|
||||||
} from "@douyinfe/semi-ui-19";
|
|
||||||
import { api, post } from "../api";
|
import { api, post } from "../api";
|
||||||
import type { Job } from "../types";
|
import type { Job } from "../types";
|
||||||
import { BusinessCard } from "./ToolCard";
|
import { AssistantDialogue } from "./AssistantDialogue";
|
||||||
import { contextLabel } from "./workspace";
|
import { ResearchInput } from "./ResearchInput";
|
||||||
|
import {
|
||||||
|
IconAIFilledLevel3,
|
||||||
|
IconPlus,
|
||||||
|
IconChevronRight,
|
||||||
|
} from "@douyinfe/semi-icons";
|
||||||
|
import { contextLabel, contextReferences, researchPrompts } from "./workspace";
|
||||||
import { chatTransport } from "./transport";
|
import { chatTransport } from "./transport";
|
||||||
import { runLabels } from "./types";
|
import { runLabels } from "./types";
|
||||||
import type {
|
import type {
|
||||||
@@ -33,12 +24,6 @@ import type {
|
|||||||
UIAction,
|
UIAction,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const MessageMarkdown = lazy(() =>
|
|
||||||
import("./MessageMarkdown").then((module) => ({
|
|
||||||
default: module.MessageMarkdown,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function ChatPanel({
|
export function ChatPanel({
|
||||||
open,
|
open,
|
||||||
context,
|
context,
|
||||||
@@ -218,8 +203,10 @@ export function ChatPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
previousFocus.current = document.activeElement as HTMLElement;
|
previousFocus.current = document.activeElement as HTMLElement;
|
||||||
const textarea = input.current?.querySelector("textarea");
|
const editor = input.current?.querySelector<HTMLElement>(
|
||||||
if (textarea && !textarea.disabled) textarea.focus();
|
'[contenteditable="true"]',
|
||||||
|
);
|
||||||
|
if (editor) editor.focus();
|
||||||
else
|
else
|
||||||
root.current
|
root.current
|
||||||
?.querySelector<HTMLButtonElement>('button[aria-label="收起研究助手"]')
|
?.querySelector<HTMLButtonElement>('button[aria-label="收起研究助手"]')
|
||||||
@@ -257,7 +244,16 @@ export function ChatPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function send() {
|
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;
|
const message = text;
|
||||||
setText("");
|
setText("");
|
||||||
setFailure("");
|
setFailure("");
|
||||||
@@ -302,6 +298,17 @@ export function ChatPanel({
|
|||||||
className="ai-chat"
|
className="ai-chat"
|
||||||
id="research-assistant"
|
id="research-assistant"
|
||||||
aria-label="AI 研究助手"
|
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) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -310,7 +317,7 @@ export function ChatPanel({
|
|||||||
if (event.key === "Tab" && window.innerWidth < 1440) {
|
if (event.key === "Tab" && window.innerWidth < 1440) {
|
||||||
const elements = [
|
const elements = [
|
||||||
...(root.current?.querySelectorAll<HTMLElement>(
|
...(root.current?.querySelectorAll<HTMLElement>(
|
||||||
'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);
|
].filter((el) => el.offsetParent !== null);
|
||||||
if (event.shiftKey && document.activeElement === elements[0]) {
|
if (event.shiftKey && document.activeElement === elements[0]) {
|
||||||
@@ -350,10 +357,14 @@ export function ChatPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<header className="ai-header">
|
<header className="ai-header">
|
||||||
<h2>AI 研究助手</h2>
|
<h2>
|
||||||
|
<IconAIFilledLevel3 size="large" aria-hidden="true" />
|
||||||
|
AI 研究助手
|
||||||
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
aria-label="收起研究助手"
|
aria-label="收起研究助手"
|
||||||
|
icon={<IconChevronRight aria-hidden="true" />}
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
@@ -376,7 +387,8 @@ export function ChatPanel({
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="tertiary"
|
colorful
|
||||||
|
icon={<IconPlus aria-hidden="true" />}
|
||||||
onClick={() => void createConversation()}
|
onClick={() => void createConversation()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
@@ -408,7 +420,8 @@ export function ChatPanel({
|
|||||||
</p>
|
</p>
|
||||||
{!conversationId && (
|
{!conversationId && (
|
||||||
<Button
|
<Button
|
||||||
type="tertiary"
|
colorful
|
||||||
|
icon={<IconAIFilledLevel3 aria-hidden="true" />}
|
||||||
onClick={() => void createConversation()}
|
onClick={() => void createConversation()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
@@ -418,48 +431,16 @@ export function ChatPanel({
|
|||||||
<span>修改和任务操作会先展示预览,确认后执行。</span>
|
<span>修改和任务操作会先展示预览,确认后执行。</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{chat.messages.map((message) => (
|
<AssistantDialogue
|
||||||
<article className={`ai-message ${message.role}`} key={message.id}>
|
messages={chat.messages}
|
||||||
<span className="ai-role">
|
streaming={streaming}
|
||||||
{message.role === "user" ? "你" : "研究助手"}
|
cards={cards}
|
||||||
</span>
|
awaitingApproval={activeRun?.status === "waiting_approval"}
|
||||||
{message.parts.map((part, index) =>
|
|
||||||
part.type === "text" ? (
|
|
||||||
message.role === "assistant" ? (
|
|
||||||
<Suspense
|
|
||||||
key={index}
|
|
||||||
fallback={<div className="ai-text">{part.text}</div>}
|
|
||||||
>
|
|
||||||
<MessageMarkdown
|
|
||||||
content={part.text}
|
|
||||||
final={
|
|
||||||
!streaming ||
|
|
||||||
message.id !== chat.messages.at(-1)?.id ||
|
|
||||||
part.state === "done"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
) : (
|
|
||||||
<div className="ai-text" key={index}>
|
|
||||||
{part.text}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : part.type === "data-tool" ? (
|
|
||||||
<BusinessCard
|
|
||||||
key={part.id ?? index}
|
|
||||||
call={cards.get(part.data.id) ?? part.data}
|
|
||||||
onDecision={decide}
|
onDecision={decide}
|
||||||
disabled={
|
|
||||||
streaming || activeRun?.status !== "waiting_approval"
|
|
||||||
}
|
|
||||||
onAction={onAction}
|
onAction={onAction}
|
||||||
timezone={timezone}
|
timezone={timezone}
|
||||||
jobs={jobs}
|
jobs={jobs}
|
||||||
/>
|
/>
|
||||||
) : null,
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
{lastRun && (
|
{lastRun && (
|
||||||
<div className="ai-run-state">
|
<div className="ai-run-state">
|
||||||
<Tag color={lastRun.status === "failed" ? "red" : "grey"}>
|
<Tag color={lastRun.status === "failed" ? "red" : "grey"}>
|
||||||
@@ -483,51 +464,28 @@ export function ChatPanel({
|
|||||||
</div>
|
</div>
|
||||||
<footer className="ai-composer" ref={input}>
|
<footer className="ai-composer" ref={input}>
|
||||||
<div className="ai-context">{contextLabel(context)}</div>
|
<div className="ai-context">{contextLabel(context)}</div>
|
||||||
<TextArea
|
<ResearchInput
|
||||||
aria-label="发送给研究助手"
|
|
||||||
placeholder={
|
|
||||||
conversationId
|
|
||||||
? "询问当前 Alpha,或描述要执行的操作…"
|
|
||||||
: "请先新建会话"
|
|
||||||
}
|
|
||||||
value={text}
|
value={text}
|
||||||
onChange={setText}
|
onChange={setText}
|
||||||
rows={3}
|
label="发送给研究助手"
|
||||||
maxCount={20000}
|
placeholder={
|
||||||
|
conversationId ? "描述研究问题或要执行的操作…" : "请先新建会话"
|
||||||
|
}
|
||||||
|
references={contextReferences(context)}
|
||||||
disabled={!ready || !conversationId || loading}
|
disabled={!ready || !conversationId || loading}
|
||||||
onKeyDown={(event) => {
|
canSubmit={!activeRun && !streaming}
|
||||||
if (
|
busy={!!activeRun || streaming}
|
||||||
event.key === "Enter" &&
|
onSubmit={() => void send()}
|
||||||
!event.shiftKey &&
|
onStop={activeRun ? () => void stop() : undefined}
|
||||||
!event.nativeEvent.isComposing
|
prompts={researchPrompts(context)}
|
||||||
) {
|
hint={
|
||||||
event.preventDefault();
|
context.page === "datasets"
|
||||||
void send();
|
? context.unsaved_field_selection
|
||||||
|
? "字段选择尚未保存;请先保存为固定研究输入。未保存备注不会发送。"
|
||||||
|
: "引用随当前页面更新,未保存备注不会发送。"
|
||||||
|
: "引用随当前页面更新。修改和任务操作会先展示预览,确认后执行。"
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="ai-send">
|
|
||||||
<small>Enter 发送 · Shift + Enter 换行</small>
|
|
||||||
{activeRun ? (
|
|
||||||
<Button type="tertiary" onClick={() => void stop()}>
|
|
||||||
停止生成
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
theme="solid"
|
|
||||||
disabled={
|
|
||||||
!ready ||
|
|
||||||
!text.trim() ||
|
|
||||||
!conversationId ||
|
|
||||||
loading ||
|
|
||||||
streaming
|
|
||||||
}
|
|
||||||
onClick={() => void send()}
|
|
||||||
>
|
|
||||||
发送
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<AIChatInput["getEditor"]>;
|
||||||
|
|
||||||
|
// 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<AIChatInput>(null);
|
||||||
|
const [editor, setEditor] = useState<Editor>();
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="research-ai-input"
|
||||||
|
onKeyDownCapture={(event) => {
|
||||||
|
if (event.key === "Enter" && event.nativeEvent.isComposing)
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AIChatInput
|
||||||
|
ref={input}
|
||||||
|
className="research-ai-editor"
|
||||||
|
defaultContent={initialContent.current}
|
||||||
|
placeholder={editorPlaceholder}
|
||||||
|
clearContentOnGenerating={false}
|
||||||
|
generating={busy}
|
||||||
|
canSend={allowed}
|
||||||
|
sendHotKey={sendHotKey}
|
||||||
|
showUploadButton={false}
|
||||||
|
showUploadFile={false}
|
||||||
|
references={references.map((reference) => ({
|
||||||
|
...reference,
|
||||||
|
type: "text",
|
||||||
|
}))}
|
||||||
|
renderReference={(reference) => (
|
||||||
|
<span
|
||||||
|
key={reference.id}
|
||||||
|
title={reference.name}
|
||||||
|
className="research-ai-reference"
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
colorful
|
||||||
|
prefixIcon={
|
||||||
|
<IconAIFilledLevel3 size="small" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{reference.name}
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
onContentChange={() => onChange(editorText(input.current?.getEditor()))}
|
||||||
|
onMessageSend={submit}
|
||||||
|
onStopGenerate={onStop}
|
||||||
|
renderConfigureArea={() => (
|
||||||
|
<span className="research-ai-shortcut">
|
||||||
|
{sendHotKey === "enter"
|
||||||
|
? "Enter 发送 · Shift + Enter 换行"
|
||||||
|
: "Shift + Enter 生成 · Enter 换行"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
renderActionArea={() =>
|
||||||
|
busy && onStop ? (
|
||||||
|
<Button
|
||||||
|
colorful
|
||||||
|
icon={<IconStop aria-hidden="true" />}
|
||||||
|
onClick={onStop}
|
||||||
|
>
|
||||||
|
停止生成
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
colorful
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconAIEditLevel1 aria-hidden="true" />}
|
||||||
|
disabled={!allowed}
|
||||||
|
loading={busy}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!value && !busy && prompts.length > 0 && (
|
||||||
|
<div className="research-ai-prompts" aria-label="研究提示">
|
||||||
|
{prompts.map((prompt) => (
|
||||||
|
<Button
|
||||||
|
key={prompt}
|
||||||
|
colorful
|
||||||
|
theme="borderless"
|
||||||
|
size="small"
|
||||||
|
icon={<IconAIEditLevel3 aria-hidden="true" />}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(prompt);
|
||||||
|
input.current?.focusEditor("end");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{prompt}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tooLong && (
|
||||||
|
<p className="error-text" role="alert">
|
||||||
|
内容不能超过 {maxLength.toLocaleString("en-US")}{" "}
|
||||||
|
字符,请精简后再发送。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hint && <div className="research-ai-hint">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { IconAIFilledLevel3, IconTick } from "@douyinfe/semi-icons";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { Button, Tag } from "@douyinfe/semi-ui-19";
|
import { Button, Tag } from "@douyinfe/semi-ui-19";
|
||||||
import { ResearchToolCard } from "../research/ResearchToolCard";
|
import { ResearchToolCard } from "../research/ResearchToolCard";
|
||||||
@@ -40,7 +41,10 @@ export function BusinessCard(
|
|||||||
return (
|
return (
|
||||||
<section className="ai-tool-card" data-status={call.status}>
|
<section className="ai-tool-card" data-status={call.status}>
|
||||||
<div className="ai-card-title">
|
<div className="ai-card-title">
|
||||||
<strong>{call.presentation?.label ?? call.name}</strong>
|
<strong>
|
||||||
|
<IconAIFilledLevel3 aria-hidden="true" />
|
||||||
|
{call.presentation?.label ?? call.name}
|
||||||
|
</strong>
|
||||||
<Tag
|
<Tag
|
||||||
color={pending ? "orange" : call.status === "failed" ? "red" : "grey"}
|
color={pending ? "orange" : call.status === "failed" ? "red" : "grey"}
|
||||||
>
|
>
|
||||||
@@ -66,6 +70,8 @@ export function BusinessCard(
|
|||||||
<div className="inline-actions">
|
<div className="inline-actions">
|
||||||
<Button
|
<Button
|
||||||
theme="solid"
|
theme="solid"
|
||||||
|
colorful
|
||||||
|
icon={<IconTick aria-hidden="true" />}
|
||||||
disabled={
|
disabled={
|
||||||
disabled || !Renderer || call.presentation?.effect !== "confirm"
|
disabled || !Renderer || call.presentation?.effect !== "confirm"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,12 @@
|
|||||||
height: 56px;
|
height: 56px;
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--semi-color-ai-background-top);
|
||||||
}
|
}
|
||||||
.ai-header h2 {
|
.ai-header h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -67,8 +71,8 @@
|
|||||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||||
}
|
}
|
||||||
.ai-message {
|
.ai-message {
|
||||||
margin-bottom: 24px;
|
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.ai-role {
|
.ai-role {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -214,9 +218,101 @@
|
|||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
.ai-card-title strong {
|
.ai-card-title strong {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
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 {
|
.ai-tool-card p {
|
||||||
margin: var(--space-2) 0;
|
margin: var(--space-2) 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,62 @@
|
|||||||
import type { PageContext, UIAction } from "./types";
|
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";
|
export type WorkspacePage = PageContext["page"] | "mcp-keys";
|
||||||
const contextLabels: Record<
|
const contextLabels: Record<
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
TextArea,
|
TextArea,
|
||||||
Toast,
|
Toast,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} 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 type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -1054,6 +1055,13 @@ export function DatasetPage({
|
|||||||
<span aria-live="polite">{summary}</span>
|
<span aria-live="polite">{summary}</span>
|
||||||
{restoreAction}
|
{restoreAction}
|
||||||
{inputAction}
|
{inputAction}
|
||||||
|
<Button
|
||||||
|
colorful
|
||||||
|
icon={<IconAIFilledLevel3 aria-hidden="true" />}
|
||||||
|
onClick={onChat}
|
||||||
|
>
|
||||||
|
AI 助手
|
||||||
|
</Button>
|
||||||
{jobStatus}
|
{jobStatus}
|
||||||
</div>
|
</div>
|
||||||
<Table<Entry>
|
<Table<Entry>
|
||||||
@@ -1129,7 +1137,13 @@ export function DatasetPage({
|
|||||||
<Button onClick={() => setFieldBrowse(initialBrowse)}>
|
<Button onClick={() => setFieldBrowse(initialBrowse)}>
|
||||||
重置筛选
|
重置筛选
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onChat}>AI 助手</Button>
|
<Button
|
||||||
|
colorful
|
||||||
|
icon={<IconAIFilledLevel3 aria-hidden="true" />}
|
||||||
|
onClick={onChat}
|
||||||
|
>
|
||||||
|
AI 助手
|
||||||
|
</Button>
|
||||||
<Button aria-label="关闭字段列表" onClick={close}>
|
<Button aria-label="关闭字段列表" onClick={close}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1172,7 +1186,13 @@ export function DatasetPage({
|
|||||||
>
|
>
|
||||||
<div className="catalog-layer" ref={detailRef}>
|
<div className="catalog-layer" ref={detailRef}>
|
||||||
<div className="catalog-tools">
|
<div className="catalog-tools">
|
||||||
<Button onClick={onChat}>AI 助手</Button>
|
<Button
|
||||||
|
colorful
|
||||||
|
icon={<IconAIFilledLevel3 aria-hidden="true" />}
|
||||||
|
onClick={onChat}
|
||||||
|
>
|
||||||
|
AI 助手
|
||||||
|
</Button>
|
||||||
<Button aria-label="关闭详情" onClick={close}>
|
<Button aria-label="关闭详情" onClick={close}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1313,7 +1333,12 @@ export function DatasetPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{draft && (
|
{draft && (
|
||||||
<Button theme="solid" onClick={() => researchInput(draft)}>
|
<Button
|
||||||
|
colorful
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconAIEditLevel3 aria-hidden="true" />}
|
||||||
|
onClick={() => researchInput(draft)}
|
||||||
|
>
|
||||||
用此输入研究
|
用此输入研究
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1330,7 +1355,7 @@ export function DatasetPage({
|
|||||||
{(draft?.scope ?? scope).universe} · Delay{" "}
|
{(draft?.scope ?? scope).universe} · Delay{" "}
|
||||||
{(draft?.scope ?? scope).delay}
|
{(draft?.scope ?? scope).delay}
|
||||||
</p>
|
</p>
|
||||||
<p>保存后可交给 Chatbox 研究,构建候选并预览回测。</p>
|
<p>保存后可交给 AI 研究助手,构建候选并预览回测。</p>
|
||||||
{draft && <p>草稿 ID:{draft.id}</p>}
|
{draft && <p>草稿 ID:{draft.id}</p>}
|
||||||
{draft && (
|
{draft && (
|
||||||
<details>
|
<details>
|
||||||
@@ -1377,7 +1402,13 @@ export function DatasetPage({
|
|||||||
{d.scope.delay}
|
{d.scope.delay}
|
||||||
</p>
|
</p>
|
||||||
<p>{formatTime(d.created_at, account?.timezone)}</p>
|
<p>{formatTime(d.created_at, account?.timezone)}</p>
|
||||||
<Button onClick={() => researchInput(d)}>用此输入研究</Button>
|
<Button
|
||||||
|
colorful
|
||||||
|
icon={<IconAIEditLevel3 aria-hidden="true" />}
|
||||||
|
onClick={() => researchInput(d)}
|
||||||
|
>
|
||||||
|
用此输入研究
|
||||||
|
</Button>
|
||||||
<details>
|
<details>
|
||||||
<summary>查看固定字段集合</summary>
|
<summary>查看固定字段集合</summary>
|
||||||
<p>{d.field_ids.join(", ")}</p>
|
<p>{d.field_ids.join(", ")}</p>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
TextArea,
|
TextArea,
|
||||||
Toast,
|
Toast,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} from "@douyinfe/semi-ui-19";
|
||||||
|
import { ResearchInput } from "../ai/ResearchInput";
|
||||||
|
import { IconAIEditLevel3 } from "@douyinfe/semi-icons";
|
||||||
import { api, post } from "../api";
|
import { api, post } from "../api";
|
||||||
import type { PageContext, UIAction } from "../ai/types";
|
import type { PageContext, UIAction } from "../ai/types";
|
||||||
import { TemplateEditor } from "./TemplateEditor";
|
import { TemplateEditor } from "./TemplateEditor";
|
||||||
@@ -210,6 +212,7 @@ export function FeaturesPage({
|
|||||||
)}
|
)}
|
||||||
</ResearchList>
|
</ResearchList>
|
||||||
<ResearchDetail
|
<ResearchDetail
|
||||||
|
width="min(100vw, max(640px, 60vw))"
|
||||||
title={asset ? asset.name : "新建特征方案"}
|
title={asset ? asset.name : "新建特征方案"}
|
||||||
visible={active && detailOpen}
|
visible={active && detailOpen}
|
||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => setDetailOpen(false)}
|
||||||
@@ -246,14 +249,49 @@ export function FeaturesPage({
|
|||||||
onChange={(name) => setDraft({ ...draft, name })}
|
onChange={(name) => setDraft({ ...draft, name })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<section className="research-ai-section">
|
||||||
|
<h4>
|
||||||
|
<IconAIEditLevel3 aria-hidden="true" />
|
||||||
经济假设
|
经济假设
|
||||||
<TextArea
|
</h4>
|
||||||
aria-label="特征经济假设"
|
<ResearchInput
|
||||||
|
maxLength={10000}
|
||||||
|
label="特征经济假设"
|
||||||
value={draft.hypothesis}
|
value={draft.hypothesis}
|
||||||
onChange={(hypothesis) => setDraft({ ...draft, hypothesis })}
|
onChange={(hypothesis) => setDraft({ ...draft, hypothesis })}
|
||||||
|
placeholder="描述特征的经济含义、处理方式和预期信号…"
|
||||||
|
references={inputs
|
||||||
|
.filter((i) => draft.input_ids.includes(i.id))
|
||||||
|
.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
name: `${i.dataset_id} · ${i.scope.region}/${i.scope.universe}/D${i.scope.delay} · ${i.field_ids.length} 字段 · ${i.id}`,
|
||||||
|
}))}
|
||||||
|
disabled={!!busy}
|
||||||
|
busy={busy === "generate"}
|
||||||
|
canSubmit={!!draft.input_ids.length}
|
||||||
|
submitLabel="AI 生成新方案"
|
||||||
|
sendHotKey="shift+enter"
|
||||||
|
onSubmit={() =>
|
||||||
|
void run("generate", async () => {
|
||||||
|
await load(
|
||||||
|
await post<FeatureAsset>("/research/generate", {
|
||||||
|
name: draft.name,
|
||||||
|
hypothesis: draft.hypothesis,
|
||||||
|
input_ids: draft.input_ids,
|
||||||
|
method: "feature",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setRevision((n) => n + 1);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
prompts={["研究字段的时序变化,构建具有经济解释的特征"]}
|
||||||
|
hint={
|
||||||
|
draft.input_ids.length
|
||||||
|
? "生成处理步骤与输出模板,保留假设供继续迭代。"
|
||||||
|
: "请先在下方关联固定输入。"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</section>
|
||||||
<label>
|
<label>
|
||||||
固定输入
|
固定输入
|
||||||
<ResearchSelect
|
<ResearchSelect
|
||||||
@@ -394,27 +432,6 @@ export function FeaturesPage({
|
|||||||
>
|
>
|
||||||
保存并转为模板
|
保存并转为模板
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
disabled={
|
|
||||||
Boolean(busy) || !draft.input_ids.length || !draft.hypothesis
|
|
||||||
}
|
|
||||||
loading={busy === "generate"}
|
|
||||||
onClick={() =>
|
|
||||||
void run("generate", async () => {
|
|
||||||
await load(
|
|
||||||
await post<FeatureAsset>("/research/generate", {
|
|
||||||
name: draft.name,
|
|
||||||
hypothesis: draft.hypothesis,
|
|
||||||
input_ids: draft.input_ids,
|
|
||||||
method: "feature",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setRevision((n) => n + 1);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
AI 生成新方案
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="research-hint">
|
<p className="research-hint">
|
||||||
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
|
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ export function ResearchList({
|
|||||||
export function ResearchDetail({
|
export function ResearchDetail({
|
||||||
title,
|
title,
|
||||||
visible,
|
visible,
|
||||||
|
width = "60%",
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
width?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
@@ -55,7 +57,7 @@ export function ResearchDetail({
|
|||||||
title={title}
|
title={title}
|
||||||
className="research-detail"
|
className="research-detail"
|
||||||
placement="right"
|
placement="right"
|
||||||
width="60%"
|
width={width}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
closeOnEsc
|
closeOnEsc
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
TextArea,
|
TextArea,
|
||||||
Toast,
|
Toast,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} from "@douyinfe/semi-ui-19";
|
||||||
|
import { ResearchInput } from "../ai/ResearchInput";
|
||||||
|
import { IconAIEditLevel3 } from "@douyinfe/semi-icons";
|
||||||
import { api, post, formatTime } from "../api";
|
import { api, post, formatTime } from "../api";
|
||||||
import type { PageContext, UIAction } from "../ai/types";
|
import type { PageContext, UIAction } from "../ai/types";
|
||||||
import { initialSettings, type SimulationSettings } from "../backtests/types";
|
import { initialSettings, type SimulationSettings } from "../backtests/types";
|
||||||
@@ -432,6 +434,7 @@ export function ResearchWorkspace({
|
|||||||
)}
|
)}
|
||||||
</ResearchList>
|
</ResearchList>
|
||||||
<ResearchDetail
|
<ResearchDetail
|
||||||
|
width="min(100vw, max(640px, 60vw))"
|
||||||
title={
|
title={
|
||||||
detailMode === "experiment"
|
detailMode === "experiment"
|
||||||
? experiment?.name || "生成记录"
|
? experiment?.name || "生成记录"
|
||||||
@@ -504,6 +507,7 @@ export function ResearchWorkspace({
|
|||||||
<p className="research-hint">
|
<p className="research-hint">
|
||||||
在数据目录中保存字段选择。跨数据集分别关联输入,跨市场使用目标范围的独立输入。
|
在数据目录中保存字段选择。跨数据集分别关联输入,跨市场使用目标范围的独立输入。
|
||||||
</p>
|
</p>
|
||||||
|
{page === "variants" && method === "settings" ? (
|
||||||
<label>
|
<label>
|
||||||
本次研究假设
|
本次研究假设
|
||||||
<TextArea
|
<TextArea
|
||||||
@@ -513,6 +517,55 @@ export function ResearchWorkspace({
|
|||||||
autosize={{ minRows: 2, maxRows: 5 }}
|
autosize={{ minRows: 2, maxRows: 5 }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
) : (
|
||||||
|
<div className="research-ai-section">
|
||||||
|
<h4>
|
||||||
|
<IconAIEditLevel3 aria-hidden="true" />
|
||||||
|
本次研究假设
|
||||||
|
</h4>
|
||||||
|
<ResearchInput
|
||||||
|
maxLength={10000}
|
||||||
|
label="本次研究假设"
|
||||||
|
value={hypothesis}
|
||||||
|
onChange={setHypothesis}
|
||||||
|
placeholder={
|
||||||
|
page === "variants"
|
||||||
|
? "描述希望保留的信号与要探索的变体方向…"
|
||||||
|
: "描述经济假设、信号方向与希望验证的关系…"
|
||||||
|
}
|
||||||
|
references={[
|
||||||
|
...selectedInputs.map((input) => ({
|
||||||
|
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
|
||||||
|
? "基于已关联的固定输入生成,保留假设供下一轮调整。"
|
||||||
|
: "请先关联固定研究输入。"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
{page !== "variants" || method === "structure" ? (
|
{page !== "variants" || method === "structure" ? (
|
||||||
<section className="research-card">
|
<section className="research-card">
|
||||||
@@ -521,13 +574,6 @@ export function ResearchWorkspace({
|
|||||||
编辑与展开{asset && ` · v${asset.version}`}
|
编辑与展开{asset && ` · v${asset.version}`}
|
||||||
{dirty && " · 未保存"}
|
{dirty && " · 未保存"}
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
|
||||||
disabled={!inputIds.length || !hypothesis || !!busy}
|
|
||||||
loading={busy === "AI 生成"}
|
|
||||||
onClick={() => void task("AI 生成", generate)}
|
|
||||||
>
|
|
||||||
AI 生成模板
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<TemplateEditor
|
<TemplateEditor
|
||||||
value={template}
|
value={template}
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ test("model settings, text stream, contextual query, confirmation and preserved
|
|||||||
await page.getByRole("button", { name: "切换研究助手" }).click();
|
await page.getByRole("button", { name: "切换研究助手" }).click();
|
||||||
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
|
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
|
||||||
await chat.getByRole("button", { name: "新会话", exact: true }).click();
|
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: "发送给研究助手" });
|
const input = chat.getByRole("textbox", { name: "发送给研究助手" });
|
||||||
await input.fill("修改当前 Alpha 的备注");
|
await input.fill("修改当前 Alpha 的备注");
|
||||||
await chat.getByRole("button", { name: "发送", exact: true }).click();
|
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: "Alpha 管理", exact: true }).click();
|
||||||
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
|
await page.getByRole("button", { name: "用户菜单", 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 chat.getByRole("button", { name: "收起研究助手" }).click();
|
||||||
await page.getByRole("button", { name: "配置", exact: true }).click();
|
await page.getByRole("button", { name: "配置", exact: true }).click();
|
||||||
await expect(
|
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();
|
await expect(chat.getByRole("cell", { name: "1.2" })).toBeVisible();
|
||||||
expect(errors).toEqual([]);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ test("fixed input, AI approval, backtest and research source remain connected",
|
|||||||
await inputSheet
|
await inputSheet
|
||||||
.getByRole("button", { name: "用此输入研究", exact: true })
|
.getByRole("button", { name: "用此输入研究", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expect(chat.locator(".ai-context")).toContainText("固定研究输入");
|
await expect(
|
||||||
|
chat.locator(".research-ai-reference").filter({ hasText: "固定输入" }),
|
||||||
|
).toContainText(input.id);
|
||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user