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 ### Domain docs
采用 single-context 布局。探索代码库前,阅读 `docs/agents/domain.md`,并按其规则加载领域文档。 采用 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): async def check_correlations(self, job_id, alpha_ids):
"""Check fixed targets against same-region submitted caches, with per-target recovery. """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. transient upstream errors and cancellation retain the task for retry.
""" """
await self.checkpoint(job_id, {"total": len(alpha_ids)}) await self.checkpoint(job_id, {"total": len(alpha_ids)})
@@ -564,7 +564,12 @@ class Runner:
result["reason"] = "平台未提供目标 Alpha 的地区" result["reason"] = "平台未提供目标 Alpha 的地区"
elif not reference_ids: elif not reference_ids:
result = calculate_correlation([], []) 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: else:
target = await self.correlation_pnl(job_id, alpha_id) target = await self.correlation_pnl(job_id, alpha_id)
references = [] 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 "status!" in second and second["hidden"] == "true"
assert all(r.method == "GET" and r.url.path == "/users/self/alphas" for r in requests) assert all(r.method == "GET" and r.url.path == "/users/self/alphas" for r in requests)
await client.close() 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, 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={() => {
+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 { 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) => onDecision={decide}
part.type === "text" ? ( onAction={onAction}
message.role === "assistant" ? ( timezone={timezone}
<Suspense jobs={jobs}
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>
))}
{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>
); );
+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 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"
} }
+97 -1
View File
@@ -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;
} }
+58
View File
@@ -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<
@@ -100,7 +100,9 @@ export function SelfCorrelationPanel({
: "grey" : "grey"
} }
> >
{correlationLabels[result.status]} {result.status === "low" && result.candidate_count === 0
? "通过(无本地基准)"
: correlationLabels[result.status]}
</Tag> </Tag>
<span className="muted"> <span className="muted">
计算于 {formatTime(result.calculated_at, timezone)} 计算于 {formatTime(result.calculated_at, timezone)}
@@ -139,7 +141,8 @@ export function SelfCorrelationPanel({
{result.status === "insufficient_data" && {result.status === "insufficient_data" &&
result.candidate_count === 0 && ( result.candidate_count === 0 && (
<p className="muted"> <p className="muted">
没有本地比较基准时可继续下方的平台提交检查;这不代表已通过相关性检查。 若目标地区已提供且没有本地比较基准,重新检测后将按通过处理,自相关值记为
0。
</p> </p>
)} )}
{result.status === "partial" && ( {result.status === "partial" && (
+1 -1
View File
@@ -224,7 +224,7 @@
position: fixed; position: fixed;
top: 0; top: 0;
right: var(--chat-space, 0px); right: var(--chat-space, 0px);
z-index: 1002; z-index: 30;
background: var(--surface); background: var(--surface);
display: flex; display: flex;
align-items: center; align-items: center;
+36 -5
View File
@@ -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>
+43 -26
View File
@@ -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>
<TextArea <IconAIEditLevel3 aria-hidden="true" />
aria-label="特征经济假设" 经济假设
</h4>
<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">
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。 保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
+3 -1
View File
@@ -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
+62 -16
View File
@@ -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,15 +507,65 @@ export function ResearchWorkspace({
<p className="research-hint"> <p className="research-hint">
在数据目录中保存字段选择。跨数据集分别关联输入,跨市场使用目标范围的独立输入。 在数据目录中保存字段选择。跨数据集分别关联输入,跨市场使用目标范围的独立输入。
</p> </p>
<label> {page === "variants" && method === "settings" ? (
本次研究假设 <label>
<TextArea 本次研究假设
aria-label="本次研究假设" <TextArea
value={hypothesis} aria-label="本次研究假设"
onChange={setHypothesis} value={hypothesis}
autosize={{ minRows: 2, maxRows: 5 }} onChange={setHypothesis}
/> 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}
+58 -2
View File
@@ -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();
});
+3 -1
View File
@@ -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([]);
}); });
+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"); ).toHaveAttribute("aria-current", "page");
await page.getByRole("button", { name: "同步任务", exact: true }).click(); await page.getByRole("button", { name: "同步任务", exact: true }).click();
await expect(page.locator(".job-panel")).toBeVisible(); 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 page.locator(".job-panel .semi-sidesheet-close").click();
await expect(page.locator(".job-panel")).not.toBeVisible(); await expect(page.locator(".job-panel")).not.toBeVisible();
await page.getByRole("button", { name: "切换研究助手" }).click(); await page.getByRole("button", { name: "切换研究助手" }).click();