Compare commits
3 Commits
9334b6f651
...
37e390a073
| Author | SHA1 | Date | |
|---|---|---|---|
| 37e390a073 | |||
| 758925edb8 | |||
| d403008f9b |
@@ -0,0 +1,15 @@
|
||||
# 统一 AI 输入、对话与研究入口
|
||||
Type: task
|
||||
Status: resolved
|
||||
|
||||
按 ../spec.md 实现并验证。主代理负责代码修改与最终验证;只读子代理核对测试启动方式。
|
||||
|
||||
## Comments
|
||||
- 2026-09-10:用户已授权本地迭代。开始前仅 AGENTS.md 有用户改动,保持不动。
|
||||
|
||||
## Answer
|
||||
- 已完成 AIChatInput 共用输入、AIChatDialogue 消息外壳、真实上下文引用、提示预填与 Semi AI 按钮/图标/Token。数据目录、模板、变体、特征生成入口已接入。
|
||||
- 保留 SDK/后端协议、流式 Markdown、工具卡片、确认及停止流程;研究详情支持窄屏,假设长度符合后端 10,000 字符限制。
|
||||
- 验证:19 项相关 Playwright E2E 全通过(本地模拟服务);最终上下文摘要调整后 2 项链路复核通过。最终 pnpm build(含 tsc)及 git diff --check 通过。
|
||||
- 人工核看助手/生成页宽窄屏、深色截图;独立只读核验未发现业务边界问题。截图位于 output/playwright/。
|
||||
- 构建提示:第三方 lottie-web 的 eval 与主分包大小警告仍存在。未执行真实模型调用或平台模拟;未发布。
|
||||
@@ -0,0 +1,9 @@
|
||||
# Semi AI 研究工作空间
|
||||
|
||||
将现有 AI 研究助手、数据目录、模板工坊、Alpha 变体、特征工程升级为 Semi 2.103.0 AI 组件体验。保留后端协议、固定输入、业务工具卡片和确认执行语义。
|
||||
|
||||
- 助手采用 AIChatInput / AIChatDialogue,复用流式 Markdown 和业务卡片;显示真实页面引用、按场景预填提示,保留会话与草稿行为。
|
||||
- 研究假设采用共用 AI 输入区,调用原有生成接口,保留假设和结构化编辑器。
|
||||
- AI 入口使用 colorful Button、Semi AI icons 与 AI tokens;普通业务状态仍保留状态语义。
|
||||
- 不启用未实现的文件上传、模型切换、历史消息修改或自动提交。
|
||||
- 验证类型、构建、助手/数据目录/研究页面的适用 E2E,并检查宽窄屏视觉。
|
||||
@@ -11,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
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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={() => {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { AIChatInput, Button, Tag } from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
IconAIEditLevel3,
|
||||
IconAIEditLevel1,
|
||||
IconAIFilledLevel3,
|
||||
IconStop,
|
||||
} from "@douyinfe/semi-icons";
|
||||
|
||||
export type ResearchReference = { id: string; name: string };
|
||||
type Editor = ReturnType<AIChatInput["getEditor"]>;
|
||||
|
||||
// JSON keeps expressions and user text literal instead of interpreting them as HTML.
|
||||
function textDocument(value: string) {
|
||||
return {
|
||||
type: "doc",
|
||||
content: value.split("\n").map((text) => ({
|
||||
type: "paragraph",
|
||||
...(text ? { content: [{ type: "text", text }] } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
function editorText(editor?: Editor) {
|
||||
return editor?.getText({ blockSeparator: "\n" }).replace(/\u200b/g, "") ?? "";
|
||||
}
|
||||
|
||||
/** Plain-text boundary for Semi's editor; references describe existing business context. */
|
||||
export function ResearchInput({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
references = [],
|
||||
disabled = false,
|
||||
canSubmit = true,
|
||||
busy = false,
|
||||
onSubmit,
|
||||
onStop,
|
||||
submitLabel = "发送",
|
||||
hint,
|
||||
prompts = [],
|
||||
sendHotKey = "enter",
|
||||
maxLength = 20000,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
references?: ResearchReference[];
|
||||
disabled?: boolean;
|
||||
canSubmit?: boolean;
|
||||
busy?: boolean;
|
||||
onSubmit: () => void;
|
||||
onStop?: () => void;
|
||||
submitLabel?: string;
|
||||
hint?: ReactNode;
|
||||
prompts?: string[];
|
||||
sendHotKey?: "enter" | "shift+enter";
|
||||
maxLength?: number;
|
||||
}) {
|
||||
const input = useRef<AIChatInput>(null);
|
||||
const [editor, setEditor] = useState<Editor>();
|
||||
const initialContent = useRef(textDocument(value));
|
||||
const currentPlaceholder = useRef(placeholder);
|
||||
currentPlaceholder.current = placeholder;
|
||||
// Tiptap initializes its placeholder extension once; read current availability lazily.
|
||||
const editorPlaceholder = useCallback(() => currentPlaceholder.current, []);
|
||||
const tooLong = value.length > maxLength;
|
||||
const allowed = !disabled && !busy && canSubmit && !!value.trim() && !tooLong;
|
||||
useEffect(() => {
|
||||
// Semi exposes its editor after its child mounts, including under StrictMode.
|
||||
let frame: number;
|
||||
const connect = () => {
|
||||
const next = input.current?.getEditor();
|
||||
if (next && !next.isDestroyed) setEditor(next);
|
||||
else frame = requestAnimationFrame(connect);
|
||||
};
|
||||
frame = requestAnimationFrame(connect);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
editor.setOptions({
|
||||
editorProps: {
|
||||
...editor.options.editorProps,
|
||||
attributes: {
|
||||
role: "textbox",
|
||||
"aria-label": label,
|
||||
"aria-multiline": "true",
|
||||
"aria-disabled": String(disabled),
|
||||
"aria-invalid": String(tooLong),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.setEditable(!disabled, false);
|
||||
}, [editor, label, disabled, tooLong]);
|
||||
useEffect(() => {
|
||||
if (editor && !editor.isDestroyed && editorText(editor) !== value)
|
||||
editor.commands.setContent(textDocument(value));
|
||||
}, [editor, value]);
|
||||
const submit = () => {
|
||||
if (allowed) onSubmit();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="research-ai-input"
|
||||
onKeyDownCapture={(event) => {
|
||||
if (event.key === "Enter" && event.nativeEvent.isComposing)
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<AIChatInput
|
||||
ref={input}
|
||||
className="research-ai-editor"
|
||||
defaultContent={initialContent.current}
|
||||
placeholder={editorPlaceholder}
|
||||
clearContentOnGenerating={false}
|
||||
generating={busy}
|
||||
canSend={allowed}
|
||||
sendHotKey={sendHotKey}
|
||||
showUploadButton={false}
|
||||
showUploadFile={false}
|
||||
references={references.map((reference) => ({
|
||||
...reference,
|
||||
type: "text",
|
||||
}))}
|
||||
renderReference={(reference) => (
|
||||
<span
|
||||
key={reference.id}
|
||||
title={reference.name}
|
||||
className="research-ai-reference"
|
||||
>
|
||||
<Tag
|
||||
colorful
|
||||
prefixIcon={
|
||||
<IconAIFilledLevel3 size="small" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
{reference.name}
|
||||
</Tag>
|
||||
</span>
|
||||
)}
|
||||
onContentChange={() => onChange(editorText(input.current?.getEditor()))}
|
||||
onMessageSend={submit}
|
||||
onStopGenerate={onStop}
|
||||
renderConfigureArea={() => (
|
||||
<span className="research-ai-shortcut">
|
||||
{sendHotKey === "enter"
|
||||
? "Enter 发送 · Shift + Enter 换行"
|
||||
: "Shift + Enter 生成 · Enter 换行"}
|
||||
</span>
|
||||
)}
|
||||
renderActionArea={() =>
|
||||
busy && onStop ? (
|
||||
<Button
|
||||
colorful
|
||||
icon={<IconStop aria-hidden="true" />}
|
||||
onClick={onStop}
|
||||
>
|
||||
停止生成
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
colorful
|
||||
theme="solid"
|
||||
icon={<IconAIEditLevel1 aria-hidden="true" />}
|
||||
disabled={!allowed}
|
||||
loading={busy}
|
||||
onClick={submit}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!value && !busy && prompts.length > 0 && (
|
||||
<div className="research-ai-prompts" aria-label="研究提示">
|
||||
{prompts.map((prompt) => (
|
||||
<Button
|
||||
key={prompt}
|
||||
colorful
|
||||
theme="borderless"
|
||||
size="small"
|
||||
icon={<IconAIEditLevel3 aria-hidden="true" />}
|
||||
disabled={disabled || busy}
|
||||
onClick={() => {
|
||||
onChange(prompt);
|
||||
input.current?.focusEditor("end");
|
||||
}}
|
||||
>
|
||||
{prompt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tooLong && (
|
||||
<p className="error-text" role="alert">
|
||||
内容不能超过 {maxLength.toLocaleString("en-US")}{" "}
|
||||
字符,请精简后再发送。
|
||||
</p>
|
||||
)}
|
||||
{hint && <div className="research-ai-hint">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IconAIFilledLevel3, IconTick } from "@douyinfe/semi-icons";
|
||||
import type { ComponentType } from "react";
|
||||
import { 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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user