Compare commits

...

3 Commits

Author SHA1 Message Date
yuxuanhui 37e390a073 fix: 无本地同地区基准时自相关通过并记为零
Deploy production / deploy (push) Successful in 56s
2026-09-10 13:16:52 +08:00
yuxuanhui 758925edb8 fix: 调整工作空间工具栏层级避免覆盖抽屉 2026-09-10 13:16:11 +08:00
yuxuanhui d403008f9b feat: 使用 Semi AI 组件升级研究助手与研究入口 2026-09-10 12:22:26 +08:00
22 changed files with 1045 additions and 179 deletions
@@ -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 与主分包大小警告仍存在。未执行真实模型调用或平台模拟;未发布。
+9
View File
@@ -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,并检查宽窄屏视觉。
+9
View File
@@ -11,3 +11,12 @@
### Domain docs
采用 single-context 布局。探索代码库前,阅读 `docs/agents/domain.md`,并按其规则加载领域文档。
### Semi MCP
新增或调整 Semi UI 组件用法(属性、布局、对齐、状态展示、交互、主题样式),排查组件专属行为或进行版本迁移时,优先使用 `semi-mcp` 核对文档与示例。纯业务逻辑修改不触发此查询。
- 先从项目依赖及锁文件确认实际版本,发现当前可用的 Semi MCP 工具,再按该版本调用 `get_semi_document`;示例被折叠时使用 `get_semi_code_block`。
- 文档不足以解释行为时,使用 `get_component_file_list` 定位文件,再按需调用 `get_file_code` 或 `get_function_code` 核对实现。
- 优先采用组件原生能力,例如 `Descriptions` 的对齐与列数属性、在 `value` 中使用 `Tag` 展示状态;确认原生能力不足后再添加局部样式。
- 工具不可用或目标版本缺失时,说明限制,改查 Semi 官方文档及本机对应版本源码、类型定义;标明版本差异。已有有效查询结果可在当前任务中复用,实际效果仍需通过适当的类型检查或浏览器验证确认。
+7 -2
View File
@@ -515,7 +515,7 @@ class Runner:
async def check_correlations(self, job_id, alpha_ids):
"""Check fixed targets against same-region submitted caches, with per-target recovery.
Missing references are reported as incomplete coverage. Authentication,
An empty same-region reference set passes locally with correlation zero. Authentication,
transient upstream errors and cancellation retain the task for retry.
"""
await self.checkpoint(job_id, {"total": len(alpha_ids)})
@@ -564,7 +564,12 @@ class Runner:
result["reason"] = "平台未提供目标 Alpha 的地区"
elif not reference_ids:
result = calculate_correlation([], [])
result["reason"] = "没有同地区已提交 Alpha 可供比较,请先同步已提交 Alpha"
# A local policy default, not a measured Pearson coefficient.
result.update(
status="low",
max_correlation=0.0,
reason="没有同地区已提交 Alpha 可供比较,按本地规则视为通过,自相关值记为 0",
)
else:
target = await self.correlation_pnl(job_id, alpha_id)
references = []
+37
View File
@@ -353,3 +353,40 @@ async def test_scoped_date_query_parameters_and_no_platform_check(settings):
assert "status!" in second and second["hidden"] == "true"
assert all(r.method == "GET" and r.url.path == "/users/self/alphas" for r in requests)
await client.close()
@pytest.mark.parametrize("region", ["USA", None])
async def test_empty_region_reference_set_passes_with_zero_but_missing_region_does_not(
app, logged_in, region
):
runner = app.state.runner # No credentials or PnL; this branch must stay local.
async with runner.sessions.begin() as db:
for raw in (
alpha("target", settings={"region": region}),
alpha("pending", settings={"region": "USA"}),
alpha("other-region", status="ACTIVE", settings={"region": "CHN"}),
):
await upsert_alpha(db, raw)
job = (
await logged_in.post(
f"{PREFIX}/sync-jobs", json={"kind": "self_correlation", "alpha_ids": ["target"]}
)
).json()
await runner.execute(job["id"])
assert (await result(runner, job["id"])).status == "completed"
report = (await logged_in.get(f"{PREFIX}/alphas/target/self-correlation")).json()["result"]
assert report["candidate_count"] == report["compared_count"] == report["skipped_count"] == 0
assert report["status"] == ("low" if region else "insufficient_data")
assert report["max_correlation"] == (0.0 if region else None)
assert not report["stale"] and report["matches"] == []
summary = (await logged_in.get(f"{PREFIX}/alphas?q=target")).json()["items"][0]["local_correlation"]
assert summary["status"] == report["status"]
assert summary["max_correlation"] == report["max_correlation"]
state = (await logged_in.get(f"{PREFIX}/alphas/target/submission")).json()
assert state["can_check"] is bool(region)
if region:
async with runner.sessions.begin() as db:
await upsert_alpha(db, alpha("new-peer", status="ACTIVE"))
report = (await logged_in.get(f"{PREFIX}/alphas/target/self-correlation")).json()["result"]
assert report["stale"]
assert not (await logged_in.get(f"{PREFIX}/alphas/target/submission")).json()["can_check"]
+11 -6
View File
@@ -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() {
/>
<div
className="workspace-tools"
hidden={settingsOpen}
hidden={settingsOpen || (chatOpen && viewport < 1440)}
inert={
(chatOpen && viewport < 1440) || catalogModal || settingsOpen
}
@@ -275,21 +279,22 @@ export default function App() {
<Tooltip content="配置" visible={settingsOpen ? false : undefined}>
<Button
aria-label="配置"
icon={<IconSetting />}
icon={<IconSetting aria-hidden="true" />}
type="tertiary"
theme="borderless"
onClick={openSettings}
/>
</Tooltip>
<Tooltip
content="Bot · 研究助手"
content="AI 研究助手"
visible={chatOpen ? false : undefined}
>
<Button
aria-label="切换研究助手"
aria-expanded={chatOpen}
aria-controls="research-assistant"
icon={<IconComment />}
icon={<IconAIFilledLevel3 aria-hidden="true" />}
colorful
type="tertiary"
theme={chatOpen ? "light" : "borderless"}
onClick={() => setChatOpen(!chatOpen)}
@@ -299,7 +304,7 @@ export default function App() {
<Badge count={pending || undefined}>
<Button
aria-label="同步任务"
icon={<IconHistory />}
icon={<IconHistory aria-hidden="true" />}
type="tertiary"
theme="borderless"
onClick={() => {
+115
View File
@@ -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>
);
},
}}
/>
);
}
+73 -115
View File
@@ -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<HTMLElement>(
'[contenteditable="true"]',
);
if (editor) editor.focus();
else
root.current
?.querySelector<HTMLButtonElement>('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<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);
if (event.shiftKey && document.activeElement === elements[0]) {
@@ -350,10 +357,14 @@ export function ChatPanel({
}}
/>
<header className="ai-header">
<h2>AI 研究助手</h2>
<h2>
<IconAIFilledLevel3 size="large" aria-hidden="true" />
AI 研究助手
</h2>
<Button
type="tertiary"
aria-label="收起研究助手"
icon={<IconChevronRight aria-hidden="true" />}
theme="borderless"
onClick={onClose}
>
@@ -376,7 +387,8 @@ export function ChatPanel({
disabled={loading}
/>
<Button
type="tertiary"
colorful
icon={<IconPlus aria-hidden="true" />}
onClick={() => void createConversation()}
disabled={loading}
>
@@ -408,7 +420,8 @@ export function ChatPanel({
</p>
{!conversationId && (
<Button
type="tertiary"
colorful
icon={<IconAIFilledLevel3 aria-hidden="true" />}
onClick={() => void createConversation()}
disabled={loading}
>
@@ -418,48 +431,16 @@ export function ChatPanel({
<span>修改和任务操作会先展示预览,确认后执行。</span>
</div>
)}
{chat.messages.map((message) => (
<article className={`ai-message ${message.role}`} key={message.id}>
<span className="ai-role">
{message.role === "user" ? "你" : "研究助手"}
</span>
{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}
disabled={
streaming || activeRun?.status !== "waiting_approval"
}
onAction={onAction}
timezone={timezone}
jobs={jobs}
/>
) : null,
)}
</article>
))}
<AssistantDialogue
messages={chat.messages}
streaming={streaming}
cards={cards}
awaitingApproval={activeRun?.status === "waiting_approval"}
onDecision={decide}
onAction={onAction}
timezone={timezone}
jobs={jobs}
/>
{lastRun && (
<div className="ai-run-state">
<Tag color={lastRun.status === "failed" ? "red" : "grey"}>
@@ -483,51 +464,28 @@ export function ChatPanel({
</div>
<footer className="ai-composer" ref={input}>
<div className="ai-context">{contextLabel(context)}</div>
<TextArea
aria-label="发送给研究助手"
placeholder={
conversationId
? "询问当前 Alpha,或描述要执行的操作…"
: "请先新建会话"
}
<ResearchInput
value={text}
onChange={setText}
rows={3}
maxCount={20000}
label="发送给研究助手"
placeholder={
conversationId ? "描述研究问题或要执行的操作…" : "请先新建会话"
}
references={contextReferences(context)}
disabled={!ready || !conversationId || loading}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void send();
}
}}
canSubmit={!activeRun && !streaming}
busy={!!activeRun || streaming}
onSubmit={() => void send()}
onStop={activeRun ? () => void stop() : undefined}
prompts={researchPrompts(context)}
hint={
context.page === "datasets"
? 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>
</aside>
);
+211
View File
@@ -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>
);
}
+7 -1
View File
@@ -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 (
<section className="ai-tool-card" data-status={call.status}>
<div className="ai-card-title">
<strong>{call.presentation?.label ?? call.name}</strong>
<strong>
<IconAIFilledLevel3 aria-hidden="true" />
{call.presentation?.label ?? call.name}
</strong>
<Tag
color={pending ? "orange" : call.status === "failed" ? "red" : "grey"}
>
@@ -66,6 +70,8 @@ export function BusinessCard(
<div className="inline-actions">
<Button
theme="solid"
colorful
icon={<IconTick aria-hidden="true" />}
disabled={
disabled || !Renderer || call.presentation?.effect !== "confirm"
}
+97 -1
View File
@@ -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;
}
+58
View File
@@ -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<
@@ -100,7 +100,9 @@ export function SelfCorrelationPanel({
: "grey"
}
>
{correlationLabels[result.status]}
{result.status === "low" && result.candidate_count === 0
? "通过(无本地基准)"
: correlationLabels[result.status]}
</Tag>
<span className="muted">
计算于 {formatTime(result.calculated_at, timezone)}
@@ -139,7 +141,8 @@ export function SelfCorrelationPanel({
{result.status === "insufficient_data" &&
result.candidate_count === 0 && (
<p className="muted">
没有本地比较基准时可继续下方的平台提交检查;这不代表已通过相关性检查。
若目标地区已提供且没有本地比较基准,重新检测后将按通过处理,自相关值记为
0。
</p>
)}
{result.status === "partial" && (
+1 -1
View File
@@ -224,7 +224,7 @@
position: fixed;
top: 0;
right: var(--chat-space, 0px);
z-index: 1002;
z-index: 30;
background: var(--surface);
display: flex;
align-items: center;
+36 -5
View File
@@ -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({
<span aria-live="polite">{summary}</span>
{restoreAction}
{inputAction}
<Button
colorful
icon={<IconAIFilledLevel3 aria-hidden="true" />}
onClick={onChat}
>
AI 助手
</Button>
{jobStatus}
</div>
<Table<Entry>
@@ -1129,7 +1137,13 @@ export function DatasetPage({
<Button onClick={() => setFieldBrowse(initialBrowse)}>
重置筛选
</Button>
<Button onClick={onChat}>AI 助手</Button>
<Button
colorful
icon={<IconAIFilledLevel3 aria-hidden="true" />}
onClick={onChat}
>
AI 助手
</Button>
<Button aria-label="关闭字段列表" onClick={close}>
关闭
</Button>
@@ -1172,7 +1186,13 @@ export function DatasetPage({
>
<div className="catalog-layer" ref={detailRef}>
<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>
@@ -1313,7 +1333,12 @@ export function DatasetPage({
</Button>
)}
{draft && (
<Button theme="solid" onClick={() => researchInput(draft)}>
<Button
colorful
theme="solid"
icon={<IconAIEditLevel3 aria-hidden="true" />}
onClick={() => researchInput(draft)}
>
用此输入研究
</Button>
)}
@@ -1330,7 +1355,7 @@ export function DatasetPage({
{(draft?.scope ?? scope).universe} · Delay{" "}
{(draft?.scope ?? scope).delay}
</p>
<p>保存后可交给 Chatbox 研究,构建候选并预览回测。</p>
<p>保存后可交给 AI 研究助手,构建候选并预览回测。</p>
{draft && <p>草稿 ID:{draft.id}</p>}
{draft && (
<details>
@@ -1377,7 +1402,13 @@ export function DatasetPage({
{d.scope.delay}
</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>
<summary>查看固定字段集合</summary>
<p>{d.field_ids.join(", ")}</p>
+43 -26
View File
@@ -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({
)}
</ResearchList>
<ResearchDetail
width="min(100vw, max(640px, 60vw))"
title={asset ? asset.name : "新建特征方案"}
visible={active && detailOpen}
onClose={() => setDetailOpen(false)}
@@ -246,14 +249,49 @@ export function FeaturesPage({
onChange={(name) => setDraft({ ...draft, name })}
/>
</label>
<label>
经济假设
<TextArea
aria-label="特征经济假设"
<section className="research-ai-section">
<h4>
<IconAIEditLevel3 aria-hidden="true" />
经济假设
</h4>
<ResearchInput
maxLength={10000}
label="特征经济假设"
value={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>
固定输入
<ResearchSelect
@@ -394,27 +432,6 @@ export function FeaturesPage({
>
保存并转为模板
</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>
<p className="research-hint">
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
+3 -1
View File
@@ -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
+62 -16
View File
@@ -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({
)}
</ResearchList>
<ResearchDetail
width="min(100vw, max(640px, 60vw))"
title={
detailMode === "experiment"
? experiment?.name || "生成记录"
@@ -504,15 +507,65 @@ export function ResearchWorkspace({
<p className="research-hint">
在数据目录中保存字段选择。跨数据集分别关联输入,跨市场使用目标范围的独立输入。
</p>
<label>
本次研究假设
<TextArea
aria-label="本次研究假设"
value={hypothesis}
onChange={setHypothesis}
autosize={{ minRows: 2, maxRows: 5 }}
/>
</label>
{page === "variants" && method === "settings" ? (
<label>
本次研究假设
<TextArea
aria-label="本次研究假设"
value={hypothesis}
onChange={setHypothesis}
autosize={{ minRows: 2, maxRows: 5 }}
/>
</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>
{page !== "variants" || method === "structure" ? (
<section className="research-card">
@@ -521,13 +574,6 @@ export function ResearchWorkspace({
编辑与展开{asset && ` · v${asset.version}`}
{dirty && " · 未保存"}
</h3>
<Button
disabled={!inputIds.length || !hypothesis || !!busy}
loading={busy === "AI 生成"}
onClick={() => void task("AI 生成", generate)}
>
AI 生成模板
</Button>
</div>
<TemplateEditor
value={template}
+58 -2
View File
@@ -93,7 +93,9 @@ test("model settings, text stream, contextual query, confirmation and preserved
await page.getByRole("button", { name: "切换研究助手" }).click();
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
await chat.getByRole("button", { name: "新会话", exact: true }).click();
await expect(chat.locator(".ai-context")).toContainText("TEST0001");
await expect(chat.locator(".research-ai-reference")).toContainText(
"TEST0001",
);
const input = chat.getByRole("textbox", { name: "发送给研究助手" });
await input.fill("修改当前 Alpha 的备注");
await chat.getByRole("button", { name: "发送", exact: true }).click();
@@ -263,7 +265,7 @@ test("Lark workspace keeps pagination, account details and chat usable at every
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
await page.getByRole("button", { name: "查看个人信息", exact: true }).click();
await expect(input).toHaveValue("保留聊天草稿");
await expect(input).toHaveText("保留聊天草稿");
await chat.getByRole("button", { name: "收起研究助手" }).click();
await page.getByRole("button", { name: "配置", exact: true }).click();
await expect(
@@ -406,3 +408,57 @@ test("Markdown streams incomplete code, finishes safely and restores history", a
await expect(chat.getByRole("cell", { name: "1.2" })).toBeVisible();
expect(errors).toEqual([]);
});
test("AI composer preserves literal text, IME input and drafts while generating", async ({
page,
}) => {
await login(page);
await configure(page);
await page.getByRole("button", { name: "切换研究助手" }).click();
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
await chat.getByRole("button", { name: "新会话", exact: true }).click();
const input = chat.getByRole("textbox", { name: "发送给研究助手" });
await expect(input.locator("p").first()).toHaveAttribute(
"data-placeholder",
"描述研究问题或要执行的操作…",
);
const sent: string[] = [];
page.on("request", (request) => {
if (
request.method() === "POST" &&
/\/ai\/conversations\/[^/]+\/runs$/.test(request.url())
)
sent.push(request.postDataJSON().message);
});
await chat
.getByRole("button", { name: "查询当前研究对象与已有结果", exact: true })
.click();
await expect(input).toHaveText("查询当前研究对象与已有结果");
expect(sent).toEqual([]);
await input.fill("SLOW <字段>");
await input.dispatchEvent("keydown", {
key: "Enter",
code: "Enter",
isComposing: true,
});
expect(sent).toEqual([]);
await input.press("Shift+Enter");
await input.pressSequentially("next");
await input.press("Enter");
await expect.poll(() => sent).toEqual(["SLOW <字段>\nnext"]);
await expect(chat.getByRole("button", { name: "停止生成" })).toBeVisible();
await input.fill("下一轮 <收益> 假设");
await chat.getByRole("button", { name: "停止生成" }).click();
await expect(chat.locator(".ai-run-state")).toContainText("已停止");
await expect(input).toHaveText("下一轮 <收益> 假设");
await input.fill("a".repeat(20001));
await expect(
chat.getByRole("button", { name: "发送", exact: true }),
).toBeDisabled();
await input.press("Enter");
expect(sent).toHaveLength(1);
await input.fill("");
await expect(
chat.getByRole("button", { name: "发送", exact: true }),
).toBeDisabled();
});
+3 -1
View File
@@ -139,6 +139,8 @@ test("fixed input, AI approval, backtest and research source remain connected",
await inputSheet
.getByRole("button", { name: "用此输入研究", exact: true })
.click();
await expect(chat.locator(".ai-context")).toContainText("固定研究输入");
await expect(
chat.locator(".research-ai-reference").filter({ hasText: "固定输入" }),
).toContainText(input.id);
expect(errors).toEqual([]);
});
+173
View File
@@ -0,0 +1,173 @@
import { expect, test } from "@playwright/test";
// UI contract fixtures: no external model call or simulation is started by these tests.
const fixed = {
id: "semi-fixed-input",
dataset_id: "TEST_FIN",
field_ids: ["TEST_FIN_001"],
field_types: { TEST_FIN_001: "MATRIX" },
collection_version: "fixed-v1",
scope: {
instrument_type: "EQUITY",
region: "USA",
universe: "TOP3000",
delay: 1,
},
};
for (const mode of ["templates", "variants", "features"] as const) {
test(`${mode}: AI generation keeps its hypothesis, fixed inputs and failure draft`, async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/api/v1/research/inputs", (route) =>
route.fulfill({ json: { items: [fixed] } }),
);
await page.route(
"**/api/v1/research/assets/semi-generated/versions",
(route) => route.fulfill({ json: [{ version: 1 }] }),
);
await page.goto(`/#${mode}`);
await page
.getByLabel("密码", { exact: true })
.fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
const feature = mode === "features";
await page
.getByRole("button", {
name: feature ? "新建特征方案" : "新建",
exact: true,
})
.click();
const sheet = page.locator(".research-detail:visible");
const input = sheet.getByRole("textbox", {
name: feature ? "特征经济假设" : "本次研究假设",
exact: true,
});
const generate = sheet.getByRole("button", {
name: feature ? "AI 生成新方案" : "AI 生成模板",
exact: true,
});
await expect(generate).toBeDisabled();
await sheet
.getByRole("combobox", {
name: feature ? "特征固定输入" : "固定研究输入",
exact: true,
})
.click();
await page
.getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) })
.click();
await input.click();
if (mode === "variants")
await sheet.getByLabel("种子 Alpha", { exact: true }).fill("TEST0001");
const hypothesis = "检验 <收益> 与规模\n第二轮保留原始假设";
await input.fill(hypothesis);
await expect(
sheet.locator(".research-ai-reference").filter({ hasText: fixed.id }),
).toBeVisible();
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
let requestBody: Record<string, unknown> | undefined;
await page.route("**/api/v1/research/generate", async (route) => {
requestBody = route.request().postDataJSON();
await pending;
const template = {
name: "AI 生成验收",
description: "",
expression: "rank({field})",
category: "template",
variables: {
field: {
kind: "field",
field_type: "MATRIX",
values: ["TEST_FIN_001"],
},
},
};
await route.fulfill({
json: {
id: "semi-generated",
kind: feature ? "feature" : "template",
name: "AI 生成验收",
version: 1,
content: feature
? {
name: "AI 生成验收",
hypothesis,
input_ids: [fixed.id],
steps: [
{
name: "排名",
rationale: "降低尺度差异",
expression: "rank(TEST_FIN_001)",
},
],
template,
}
: template,
},
});
});
await input.press("Shift+Enter");
await expect
.poll(() => requestBody)
.toMatchObject({
hypothesis,
input_ids: [fixed.id],
method: feature
? "feature"
: mode === "variants"
? "structure"
: "template",
});
if (mode === "variants")
expect(requestBody?.parent_alpha_ids).toEqual(["TEST0001"]);
await expect(generate).toBeDisabled();
await expect(input).toContainText("第二轮保留原始假设");
release();
await expect(
sheet.getByRole("textbox", { name: "模板名称", exact: true }),
).toHaveValue("AI 生成验收");
await expect(generate).toBeEnabled();
await expect(input).toContainText("检验 <收益> 与规模");
await page.screenshot({ path: `../output/playwright/semi-ai-${mode}.png` });
await page.setViewportSize({ width: 390, height: 900 });
await expect(generate).toBeInViewport();
expect(await sheet.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(
true,
);
await page.screenshot({
path: `../output/playwright/semi-ai-${mode}-390.png`,
});
if (mode === "templates") {
await page.evaluate(() =>
document.body.setAttribute("theme-mode", "dark"),
);
await page.screenshot({
path: "../output/playwright/semi-ai-templates-dark.png",
});
await page.evaluate(() =>
document.body.setAttribute("theme-mode", "light"),
);
}
await page.route("**/api/v1/research/generate", (route) =>
route.fulfill({ status: 503, json: { detail: "模拟模型暂时不可用" } }),
);
await generate.click();
await expect(
sheet.getByText("模拟模型暂时不可用", { exact: true }),
).toBeVisible();
await expect(input).toContainText("第二轮保留原始假设");
await expect(generate).toBeEnabled();
await input.fill("a".repeat(10001));
await expect(generate).toBeDisabled();
await expect(
sheet.getByRole("alert").filter({ hasText: "10,000" }),
).toBeVisible();
expect(errors).toEqual([]);
});
}
+12
View File
@@ -62,6 +62,18 @@ test("sidebar geometry, recent navigation, user menu and compact tools", async (
).toHaveAttribute("aria-current", "page");
await page.getByRole("button", { name: "同步任务", exact: true }).click();
await expect(page.locator(".job-panel")).toBeVisible();
// The drawer must receive pointer events where the global tools sit behind it.
await expect.poll(() =>
page.getByRole("toolbar").getByRole("button").evaluateAll((buttons) =>
buttons.every((button) => {
const rect = button.getBoundingClientRect();
return Boolean(document.elementFromPoint(
rect.x + rect.width / 2,
rect.y + rect.height / 2,
)?.closest(".job-panel"));
}),
),
).toBe(true);
await page.locator(".job-panel .semi-sidesheet-close").click();
await expect(page.locator(".job-panel")).not.toBeVisible();
await page.getByRole("button", { name: "切换研究助手" }).click();