feat: add versioned research templates and alpha variants
This commit is contained in:
+145
-119
@@ -1,22 +1,30 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Banner,
|
||||
Button,
|
||||
Input,
|
||||
LocaleProvider,
|
||||
Spin,
|
||||
SideSheet,
|
||||
Toast,
|
||||
Tooltip,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN";
|
||||
import { api, post } from "./api";
|
||||
import { IconSetting, IconComment, IconHistory } from "@douyinfe/semi-icons";
|
||||
import { ResearchWorkspace } from "./research/ResearchWorkspace";
|
||||
import { OperatorsPage } from "./research/OperatorsPage";
|
||||
import { AppSidebar } from "./components/AppSidebar";
|
||||
import "./components/sidebar.css";
|
||||
import { api, patch, post } from "./api";
|
||||
import type { Account, Job } from "./types";
|
||||
import { AccountPage } from "./pages/AccountPage";
|
||||
import { DatasetPage } from "./pages/DatasetPage";
|
||||
import { AlphaPage } from "./pages/AlphaPage";
|
||||
import { JobPanel } from "./components/JobPanel";
|
||||
import { BacktestPage } from "./backtests/BacktestPage";
|
||||
import { WorkspacePreferences } from "./components/WorkspacePreferences";
|
||||
import { ModelSettingsPanel } from "./ai/ModelSettingsPanel";
|
||||
import { ChatPanel } from "./ai/ChatPanel";
|
||||
import { actionDestination, pageFromHash } from "./ai/workspace";
|
||||
import type { WorkspacePage } from "./ai/workspace";
|
||||
@@ -25,6 +33,7 @@ import type { PageContext, Resource, UIAction } from "./ai/types";
|
||||
export default function App() {
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [page, setPage] = useState<WorkspacePage>(() =>
|
||||
pageFromHash(location.hash),
|
||||
@@ -35,8 +44,22 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (page === "backtests") setVisitedBacktests(true);
|
||||
}, [page]);
|
||||
const [visitedResearch, setVisitedResearch] = useState<string[]>([]);
|
||||
const [researchContext, setResearchContext] = useState<PageContext>({
|
||||
page: "templates",
|
||||
});
|
||||
useEffect(() => {
|
||||
if (["operators", "templates", "variants"].includes(page))
|
||||
setVisitedResearch((old) => (old.includes(page) ? old : [...old, page]));
|
||||
}, [page]);
|
||||
const [catalogModal, setCatalogModal] = useState(false);
|
||||
const [showJobs, setShowJobs] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const openSettings = () => {
|
||||
setShowJobs(false);
|
||||
setChatOpen(false);
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [resourceVersions, setResourceVersions] = useState<
|
||||
Record<Resource, number>
|
||||
@@ -102,6 +125,7 @@ export default function App() {
|
||||
.then(() => setAuthenticated(true))
|
||||
.catch(() => setAuthenticated(false));
|
||||
const expired = () => {
|
||||
setSettingsOpen(false);
|
||||
setAuthenticated(false);
|
||||
setAccount(null);
|
||||
setJobs([]);
|
||||
@@ -171,6 +195,7 @@ export default function App() {
|
||||
const logout = async () => {
|
||||
try {
|
||||
await post("/auth/logout");
|
||||
setSettingsOpen(false);
|
||||
setAuthenticated(false);
|
||||
setChatOpen(false);
|
||||
setShowJobs(false);
|
||||
@@ -199,110 +224,85 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="workspace">
|
||||
<aside
|
||||
className="sidebar"
|
||||
inert={(chatOpen && viewport < 1440) || catalogModal}
|
||||
aria-hidden={catalogModal || undefined}
|
||||
<div
|
||||
className={`workspace ${sidebarCollapsed ? "sidebar-collapsed" : ""}`}
|
||||
>
|
||||
<AppSidebar
|
||||
account={account}
|
||||
page={page}
|
||||
collapsed={sidebarCollapsed}
|
||||
blocked={
|
||||
(chatOpen && viewport < 1440) || catalogModal || settingsOpen
|
||||
}
|
||||
onCollapse={() => setSidebarCollapsed((value) => !value)}
|
||||
onNavigate={changePage}
|
||||
onLogout={() => void logout()}
|
||||
onTheme={() => {
|
||||
if (!account) return;
|
||||
void patch("/account/preferences", {
|
||||
display_name: account.display_name,
|
||||
timezone: account.timezone,
|
||||
page_size: account.page_size,
|
||||
theme: account.theme === "dark" ? "light" : "dark",
|
||||
})
|
||||
.then(() => refresh())
|
||||
.catch((error: Error) => Toast.error(error.message));
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="workspace-tools"
|
||||
hidden={settingsOpen}
|
||||
inert={
|
||||
(chatOpen && viewport < 1440) || catalogModal || settingsOpen
|
||||
}
|
||||
role="toolbar"
|
||||
aria-label="工作空间工具"
|
||||
>
|
||||
<div className="brand">
|
||||
<span className="brand-mark">α</span>
|
||||
<div>Alpha 研究</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Alpha 管理"
|
||||
className={`nav-item ${page === "alphas" ? "active" : ""}`}
|
||||
aria-current={page === "alphas" ? "page" : undefined}
|
||||
onClick={() => changePage("alphas")}
|
||||
<Tooltip content="配置" visible={settingsOpen ? false : undefined}>
|
||||
<Button
|
||||
aria-label="配置"
|
||||
icon={<IconSetting />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
onClick={openSettings}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content="Bot · 研究助手"
|
||||
visible={chatOpen ? false : undefined}
|
||||
>
|
||||
Alpha 管理
|
||||
</button>
|
||||
<button
|
||||
aria-label="数据集"
|
||||
className={`nav-item ${page === "datasets" ? "active" : ""}`}
|
||||
aria-current={page === "datasets" ? "page" : undefined}
|
||||
onClick={() => changePage("datasets")}
|
||||
>
|
||||
数据集
|
||||
</button>
|
||||
<button
|
||||
aria-label="个人信息"
|
||||
className={`nav-item ${page === "account" ? "active" : ""}`}
|
||||
aria-current={page === "account" ? "page" : undefined}
|
||||
onClick={() => changePage("account")}
|
||||
>
|
||||
个人信息
|
||||
</button>
|
||||
<button
|
||||
aria-label="回测研究"
|
||||
className={`nav-item ${page === "backtests" ? "active" : ""}`}
|
||||
aria-current={page === "backtests" ? "page" : undefined}
|
||||
onClick={() => changePage("backtests")}
|
||||
>
|
||||
回测研究
|
||||
</button>
|
||||
<div className="sidebar-bottom">
|
||||
<div className="connection-line">
|
||||
<i
|
||||
className={
|
||||
account?.connection_status === "connected"
|
||||
? "connected"
|
||||
: ""
|
||||
}
|
||||
<Button
|
||||
aria-label="切换研究助手"
|
||||
aria-expanded={chatOpen}
|
||||
aria-controls="research-assistant"
|
||||
icon={<IconComment />}
|
||||
type="tertiary"
|
||||
theme={chatOpen ? "light" : "borderless"}
|
||||
onClick={() => setChatOpen(!chatOpen)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="同步任务" visible={showJobs ? false : undefined}>
|
||||
<Badge count={pending || undefined}>
|
||||
<Button
|
||||
aria-label="同步任务"
|
||||
icon={<IconHistory />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
onClick={() => {
|
||||
focusBusiness();
|
||||
setShowJobs(true);
|
||||
}}
|
||||
/>
|
||||
{account?.connection_status === "connected"
|
||||
? "WorldQuant 已连接"
|
||||
: "WorldQuant 未连接"}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
className="main-shell"
|
||||
inert={(chatOpen && viewport < 1440) || catalogModal}
|
||||
inert={
|
||||
(chatOpen && viewport < 1440) || catalogModal || settingsOpen
|
||||
}
|
||||
aria-hidden={catalogModal || undefined}
|
||||
>
|
||||
<header className="topbar">
|
||||
<div className="breadcrumbs">研究工作空间</div>
|
||||
<div className="top-actions">
|
||||
<Badge count={pending}>
|
||||
<Button
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
onClick={() => {
|
||||
focusBusiness();
|
||||
setShowJobs(true);
|
||||
}}
|
||||
>
|
||||
同步任务
|
||||
</Button>
|
||||
</Badge>
|
||||
<Button
|
||||
aria-label="切换研究助手"
|
||||
aria-expanded={chatOpen}
|
||||
aria-controls="research-assistant"
|
||||
type="tertiary"
|
||||
theme={chatOpen ? "light" : "borderless"}
|
||||
onClick={() => setChatOpen(!chatOpen)}
|
||||
>
|
||||
AI 助手
|
||||
</Button>
|
||||
<div className="top-divider" />
|
||||
<Avatar size="small" color="grey">
|
||||
{account?.display_name.slice(0, 1) || "研"}
|
||||
</Avatar>
|
||||
<span className="account-name">
|
||||
{account?.display_name ?? "研究员"}
|
||||
</span>
|
||||
<Button
|
||||
type="tertiary"
|
||||
aria-label="退出登录"
|
||||
theme="borderless"
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
退出
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main
|
||||
className={`page-content ${page !== "account" ? "bounded-page" : "account-page"}`}
|
||||
>
|
||||
@@ -370,8 +370,44 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{visitedResearch.includes("operators") && (
|
||||
<div className="alpha-page-view" hidden={page !== "operators"}>
|
||||
<OperatorsPage active={page === "operators"} />
|
||||
</div>
|
||||
)}
|
||||
{(["templates", "variants"] as const).map(
|
||||
(researchPage) =>
|
||||
visitedResearch.includes(researchPage) && (
|
||||
<div
|
||||
className="alpha-page-view"
|
||||
key={researchPage}
|
||||
hidden={page !== researchPage}
|
||||
>
|
||||
<ResearchWorkspace
|
||||
page={researchPage}
|
||||
active={page === researchPage}
|
||||
action={aiAction}
|
||||
onAction={handleAction}
|
||||
onContext={setResearchContext}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<SideSheet
|
||||
title="配置"
|
||||
className="settings-drawer"
|
||||
placement="right"
|
||||
visible={settingsOpen}
|
||||
width={Math.min(560, viewport)}
|
||||
keepDOM
|
||||
closeOnEsc
|
||||
onCancel={() => setSettingsOpen(false)}
|
||||
>
|
||||
<ModelSettingsPanel />
|
||||
<WorkspacePreferences account={account} onChange={actionDone} />
|
||||
</SideSheet>
|
||||
<JobPanel
|
||||
visible={showJobs && !(viewport < 1440 && chatOpen)}
|
||||
chatOffset={chatOffset}
|
||||
@@ -384,17 +420,6 @@ export default function App() {
|
||||
changePage("account");
|
||||
}}
|
||||
/>
|
||||
{!chatOpen && !catalogModal && (
|
||||
<Button
|
||||
className="ai-launcher"
|
||||
aria-label="打开研究助手"
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
onClick={() => setChatOpen(true)}
|
||||
>
|
||||
AI 研究助手
|
||||
</Button>
|
||||
)}
|
||||
{chatOpen && viewport < 1440 && (
|
||||
<div
|
||||
className="ai-mask"
|
||||
@@ -412,6 +437,15 @@ export default function App() {
|
||||
onClose={() => setChatOpen(false)}
|
||||
context={
|
||||
{
|
||||
operators: { page: "operators" as const },
|
||||
templates:
|
||||
researchContext.page === "templates"
|
||||
? researchContext
|
||||
: { page: "templates" as const },
|
||||
variants:
|
||||
researchContext.page === "variants"
|
||||
? researchContext
|
||||
: { page: "variants" as const },
|
||||
alphas: alphaContext,
|
||||
datasets: datasetContext,
|
||||
backtests: backtestContext,
|
||||
@@ -419,15 +453,7 @@ export default function App() {
|
||||
}[page]
|
||||
}
|
||||
timezone={account?.timezone}
|
||||
onSettings={() => {
|
||||
focusBusiness();
|
||||
changePage("account");
|
||||
requestAnimationFrame(() =>
|
||||
document
|
||||
.getElementById("model-settings")
|
||||
?.scrollIntoView({ block: "start" }),
|
||||
);
|
||||
}}
|
||||
onSettings={openSettings}
|
||||
onChanged={aiChanged}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Button, Tag } from "@douyinfe/semi-ui-19";
|
||||
import { ResearchToolCard } from "../research/ResearchToolCard";
|
||||
import { BacktestToolCard } from "../backtests/BacktestToolCard";
|
||||
import { CatalogToolCard } from "../research/CatalogToolCard";
|
||||
import { AlphaToolCard } from "./AlphaToolCard";
|
||||
@@ -9,6 +10,7 @@ import type { ToolCardProps } from "./types";
|
||||
// The server owns labels, effects and refresh targets. This is the only place
|
||||
// where a new presentation shape joins the workspace; tool names are domain-local.
|
||||
const renderers: Record<string, ComponentType<ToolCardProps>> = {
|
||||
research: ResearchToolCard,
|
||||
alpha: AlphaToolCard,
|
||||
jobs: JobToolCard,
|
||||
catalog: CatalogToolCard,
|
||||
|
||||
@@ -11,7 +11,16 @@ export type ModelSettings = {
|
||||
test_results: Record<string, { ok: boolean; message: string }>;
|
||||
};
|
||||
export type PageContext = {
|
||||
page: "alphas" | "account" | "datasets" | "backtests";
|
||||
page:
|
||||
| "alphas"
|
||||
| "account"
|
||||
| "datasets"
|
||||
| "backtests"
|
||||
| "operators"
|
||||
| "templates"
|
||||
| "variants";
|
||||
research_asset_id?: string;
|
||||
research_experiment_id?: string;
|
||||
catalog_scope?: {
|
||||
instrument_type: string;
|
||||
region: string;
|
||||
@@ -34,6 +43,9 @@ export type AlphaUIAction =
|
||||
| { type: "open_alpha"; alpha_id: string; nonce: number }
|
||||
| { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
|
||||
export type UIAction =
|
||||
| { type: "open_template"; asset_id: string; version?: number; nonce: number }
|
||||
| { type: "open_experiment"; experiment_id: string; nonce: number }
|
||||
| { type: "open_variant"; alpha_id: string; nonce: number }
|
||||
| { type: "open_conversation"; conversation_id: string; nonce: number }
|
||||
| { type: "open_research_input"; input_id: string; nonce: number }
|
||||
| { type: "open_backtest"; run_id: string; nonce: number }
|
||||
|
||||
@@ -4,6 +4,10 @@ export type WorkspacePage = PageContext["page"];
|
||||
const contextLabels: Record<WorkspacePage, (context: PageContext) => string> = {
|
||||
alphas: (context) =>
|
||||
`上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`,
|
||||
operators: () => "上下文:算子库",
|
||||
templates: (context) =>
|
||||
`上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`,
|
||||
variants: () => "上下文:Alpha 变体",
|
||||
account: () => "上下文:个人信息页",
|
||||
backtests: () => "上下文:回测研究",
|
||||
datasets: (context) =>
|
||||
@@ -27,6 +31,9 @@ type Destination = {
|
||||
};
|
||||
// Exhaustive action destinations prevent a new action silently falling into Alpha.
|
||||
const destinations: Record<UIAction["type"], Destination> = {
|
||||
open_template: { page: "templates", chat: "responsive" },
|
||||
open_experiment: { page: "templates", chat: "responsive" },
|
||||
open_variant: { page: "variants", chat: "responsive" },
|
||||
open_conversation: { chat: "open" },
|
||||
open_research_input: { page: "datasets", chat: "close" },
|
||||
open_backtest: { page: "backtests", chat: "responsive" },
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
} from "../types";
|
||||
import { PnlChart } from "./PnlChart";
|
||||
import { SelfCorrelationPanel } from "./SelfCorrelationPanel";
|
||||
import { ComparisonPanel } from "../research/ComparisonPanel";
|
||||
import { AlphaSources } from "../research/AlphaSources";
|
||||
import type { UIAction } from "../ai/types";
|
||||
|
||||
@@ -216,6 +217,20 @@ export function AlphaDetail({
|
||||
<Button>在 BRAIN 中打开 ↗</Button>
|
||||
</a>
|
||||
</div>
|
||||
{detail.alpha_type === "REGULAR" &&
|
||||
detail.language === "FASTEXPR" && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_variant",
|
||||
alpha_id: detail.id,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
研究此 Alpha 的变体
|
||||
</Button>
|
||||
)}
|
||||
<Tabs activeKey={tab} onChange={setTab}>
|
||||
<TabPane tab="概览与表达式" itemKey="overview">
|
||||
<div className="detail-section">
|
||||
@@ -320,6 +335,11 @@ export function AlphaDetail({
|
||||
/>
|
||||
)}
|
||||
</TabPane>
|
||||
<TabPane tab="基线比较" itemKey="compare">
|
||||
{tab === "compare" && (
|
||||
<ComparisonPanel key={detail.id} baseline={detail.id} />
|
||||
)}
|
||||
</TabPane>
|
||||
<TabPane tab="研究来源" itemKey="sources">
|
||||
{tab === "sources" && id && (
|
||||
<AlphaSources
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Avatar, Modal, Popover, Tooltip } from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
IconBeaker,
|
||||
IconChevronDown,
|
||||
IconClose,
|
||||
IconCommand,
|
||||
IconExit,
|
||||
IconGridView,
|
||||
IconList,
|
||||
IconMoon,
|
||||
IconSidebar,
|
||||
IconUser,
|
||||
} from "@douyinfe/semi-icons";
|
||||
import type { Account } from "../types";
|
||||
import type { WorkspacePage } from "../ai/workspace";
|
||||
|
||||
const navigation = [
|
||||
{ id: "datasets", label: "数据目录", icon: IconList, group: "数据与素材" },
|
||||
{ id: "operators", label: "算子库", icon: IconList, group: "数据与素材" },
|
||||
{
|
||||
id: "templates",
|
||||
label: "模板工坊",
|
||||
icon: IconGridView,
|
||||
group: "数据与素材",
|
||||
},
|
||||
{ id: "variants", label: "Alpha 变体", icon: IconBeaker, group: "研究实验" },
|
||||
{ id: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" },
|
||||
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
|
||||
{ id: "account", label: "个人信息", icon: IconUser, group: "" },
|
||||
] as const;
|
||||
|
||||
export function AppSidebar({
|
||||
account,
|
||||
page,
|
||||
collapsed,
|
||||
blocked,
|
||||
onCollapse,
|
||||
onNavigate,
|
||||
onLogout,
|
||||
onTheme,
|
||||
}: {
|
||||
account: Account | null;
|
||||
page: WorkspacePage;
|
||||
collapsed: boolean;
|
||||
blocked: boolean;
|
||||
onCollapse: () => void;
|
||||
onNavigate: (page: WorkspacePage) => void;
|
||||
onLogout: () => void;
|
||||
onTheme: () => void;
|
||||
}) {
|
||||
const [userOpen, setUserOpen] = useState(false);
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const [recent, setRecent] = useState<WorkspacePage[]>([page]);
|
||||
const name = account?.display_name || "研究员";
|
||||
useEffect(() => {
|
||||
setRecent((old) => [page, ...old.filter((id) => id !== page)].slice(0, 4));
|
||||
}, [page]);
|
||||
useEffect(() => {
|
||||
if (blocked) {
|
||||
setUserOpen(false);
|
||||
setCommandOpen(false);
|
||||
}
|
||||
const shortcut = (event: KeyboardEvent) => {
|
||||
if (
|
||||
!blocked &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.key.toLowerCase() === "k"
|
||||
) {
|
||||
event.preventDefault();
|
||||
setCommandOpen((open) => !open);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", shortcut);
|
||||
return () => window.removeEventListener("keydown", shortcut);
|
||||
}, [blocked]);
|
||||
function navigate(id: WorkspacePage) {
|
||||
setUserOpen(false);
|
||||
setCommandOpen(false);
|
||||
onNavigate(id);
|
||||
}
|
||||
function closeRecent(id: WorkspacePage) {
|
||||
if (recent.length <= 1) return;
|
||||
const next = recent.filter((item) => item !== id);
|
||||
setRecent(next);
|
||||
if (id === page) navigate(next[0]);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
className={`app-sidebar ${collapsed ? "is-collapsed" : ""}`}
|
||||
inert={blocked}
|
||||
aria-label="工作空间导航"
|
||||
>
|
||||
<div className="sidebar-user-row">
|
||||
<Popover
|
||||
trigger="click"
|
||||
position={collapsed ? "rightTop" : "bottomLeft"}
|
||||
visible={userOpen}
|
||||
onVisibleChange={setUserOpen}
|
||||
showArrow={false}
|
||||
content={
|
||||
<div className="sidebar-user-menu">
|
||||
<div className="sidebar-user-details">
|
||||
<strong>{name}</strong>
|
||||
{account?.email && account.email !== name && (
|
||||
<span>{account.email}</span>
|
||||
)}
|
||||
<span>
|
||||
WorldQuant{" "}
|
||||
{account?.connection_status === "connected"
|
||||
? "已连接"
|
||||
: "未连接"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-label="查看个人信息"
|
||||
onClick={() => navigate("account")}
|
||||
>
|
||||
<IconUser />
|
||||
查看个人信息
|
||||
</button>
|
||||
<button
|
||||
aria-label={
|
||||
account?.theme === "dark" ? "切换浅色主题" : "切换深色主题"
|
||||
}
|
||||
onClick={onTheme}
|
||||
>
|
||||
<IconMoon />
|
||||
{account?.theme === "dark" ? "切换浅色主题" : "切换深色主题"}
|
||||
</button>
|
||||
<button
|
||||
aria-label="退出登录"
|
||||
onClick={() => {
|
||||
setUserOpen(false);
|
||||
onLogout();
|
||||
}}
|
||||
>
|
||||
<IconExit />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="sidebar-user-trigger"
|
||||
aria-label="用户菜单"
|
||||
aria-expanded={userOpen}
|
||||
>
|
||||
<Avatar size="small" color="grey">
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="account-name">{name}</span>
|
||||
<IconChevronDown size="small" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Popover>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-command">
|
||||
<button onClick={() => setCommandOpen(true)}>
|
||||
<IconCommand />
|
||||
<span>快捷导航</span>
|
||||
<kbd>⌘K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<nav className="sidebar-nav" aria-label="主导航">
|
||||
{["数据与素材", "研究实验", "研究成果"].map((group) => (
|
||||
<div key={group}>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-heading">{group}</div>
|
||||
)}
|
||||
{navigation
|
||||
.filter((item) => item.group === group)
|
||||
.map(({ id, label, icon: Icon }) => (
|
||||
<Tooltip
|
||||
key={id}
|
||||
content={label}
|
||||
position="right"
|
||||
visible={collapsed ? undefined : false}
|
||||
>
|
||||
<button
|
||||
className={`sidebar-nav-item ${id === page ? "active" : ""}`}
|
||||
aria-label={label}
|
||||
aria-current={id === page ? "page" : undefined}
|
||||
onClick={() => navigate(id)}
|
||||
>
|
||||
<Icon style={{ fontSize: 17 }} />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
{!collapsed && (
|
||||
<div className="recent-heading">
|
||||
<span>最近访问</span>
|
||||
<button onClick={() => setRecent([page])}>全部关闭</button>
|
||||
</div>
|
||||
)}
|
||||
<nav aria-label="最近访问">
|
||||
{recent.map((id) => {
|
||||
const item = navigation.find((item) => item.id === id)!;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`recent-item ${id === page ? "active" : ""}`}
|
||||
>
|
||||
<Tooltip
|
||||
content={item.label}
|
||||
position="right"
|
||||
visible={collapsed ? undefined : false}
|
||||
>
|
||||
<button
|
||||
className="recent-link"
|
||||
aria-label={`最近访问:${item.label}`}
|
||||
onClick={() => navigate(id)}
|
||||
>
|
||||
<Icon style={{ fontSize: 15 }} />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!collapsed && (
|
||||
<button
|
||||
className="recent-close"
|
||||
aria-label={`关闭最近访问:${item.label}`}
|
||||
disabled={recent.length === 1}
|
||||
onClick={() => closeRecent(id)}
|
||||
>
|
||||
<IconClose size="small" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-collapse">
|
||||
<Tooltip
|
||||
content={collapsed ? "展开导航" : "收起导航"}
|
||||
position="right"
|
||||
>
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
aria-label={collapsed ? "展开导航" : "收起导航"}
|
||||
>
|
||||
<IconSidebar />
|
||||
{!collapsed && <span>收起导航</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<Modal
|
||||
title="快捷导航"
|
||||
visible={commandOpen}
|
||||
footer={null}
|
||||
onCancel={() => setCommandOpen(false)}
|
||||
width={420}
|
||||
>
|
||||
<div className="command-items">
|
||||
{navigation.map(({ id, label, icon: Icon }) => (
|
||||
<button key={id} onClick={() => navigate(id)}>
|
||||
<Icon />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Input, Select, Toast } from "@douyinfe/semi-ui-19";
|
||||
import { formatTime, patch } from "../api";
|
||||
import type { Account } from "../types";
|
||||
|
||||
export function WorkspacePreferences({
|
||||
account,
|
||||
onChange,
|
||||
}: {
|
||||
account: Account | null;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preferences, setPreferences] = useState({
|
||||
display_name: "研究员",
|
||||
theme: "light",
|
||||
timezone: "Asia/Shanghai",
|
||||
page_size: 25,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (account)
|
||||
setPreferences({
|
||||
display_name: account.display_name,
|
||||
theme: account.theme,
|
||||
timezone: account.timezone,
|
||||
page_size: account.page_size,
|
||||
});
|
||||
}, [
|
||||
account?.display_name,
|
||||
account?.theme,
|
||||
account?.timezone,
|
||||
account?.page_size,
|
||||
]);
|
||||
if (!account) return null;
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await patch("/account/preferences", preferences);
|
||||
onChange();
|
||||
Toast.success("已保存");
|
||||
} catch (error) {
|
||||
Toast.error((error as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="workspace-preferences">
|
||||
<div className="section-heading">
|
||||
<h2>工作空间偏好</h2>
|
||||
<span className="muted">
|
||||
更新于 {formatTime(account.last_synced_at, account.timezone)}
|
||||
</span>
|
||||
</div>
|
||||
<form
|
||||
className="preferences-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<div className="preferences-grid">
|
||||
<label>
|
||||
显示名称
|
||||
<Input
|
||||
aria-label="显示名称"
|
||||
value={preferences.display_name}
|
||||
onChange={(display_name) =>
|
||||
setPreferences({ ...preferences, display_name })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
主题
|
||||
<Select
|
||||
aria-label="主题"
|
||||
value={preferences.theme}
|
||||
optionList={[
|
||||
{ value: "light", label: "浅色" },
|
||||
{ value: "dark", label: "深色" },
|
||||
]}
|
||||
onChange={(theme) =>
|
||||
setPreferences({ ...preferences, theme: String(theme) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
时区
|
||||
<Input
|
||||
aria-label="时区"
|
||||
value={preferences.timezone}
|
||||
onChange={(timezone) =>
|
||||
setPreferences({ ...preferences, timezone })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
每页数量
|
||||
<Select
|
||||
aria-label="每页数量"
|
||||
value={preferences.page_size}
|
||||
optionList={[25, 50, 100].map((value) => ({
|
||||
value,
|
||||
label: `${value} 条`,
|
||||
}))}
|
||||
onChange={(page_size) =>
|
||||
setPreferences({
|
||||
...preferences,
|
||||
page_size: Number(page_size),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
theme="solid"
|
||||
loading={busy}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
保存偏好
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
.workspace {
|
||||
--sidebar-width: 230px;
|
||||
padding-left: var(--sidebar-width);
|
||||
transition: padding-left 200ms ease-out;
|
||||
}
|
||||
.workspace.sidebar-collapsed {
|
||||
--sidebar-width: 64px;
|
||||
}
|
||||
.app-sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 30;
|
||||
width: var(--sidebar-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--semi-color-bg-1);
|
||||
border-right: 1px solid var(--semi-color-border);
|
||||
color: var(--semi-color-text-1);
|
||||
transition: width 200ms ease-out;
|
||||
}
|
||||
.app-sidebar button,
|
||||
.sidebar-user-menu button,
|
||||
.command-items button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
border-radius: var(--semi-border-radius-large);
|
||||
}
|
||||
.app-sidebar button:hover,
|
||||
.sidebar-user-menu button:hover,
|
||||
.command-items button:hover {
|
||||
background: var(--semi-color-fill-0);
|
||||
color: var(--semi-color-text-0);
|
||||
}
|
||||
.app-sidebar button:focus-visible {
|
||||
outline: 2px solid var(--semi-color-primary-light-active);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.sidebar-user-row {
|
||||
height: 56px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-user-trigger {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.sidebar-user-trigger .semi-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-user-trigger .account-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-command {
|
||||
padding: 0 12px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-command button {
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
color: var(--semi-color-text-2);
|
||||
}
|
||||
.sidebar-command button > span:not(.semi-icon) {
|
||||
flex: 1;
|
||||
}
|
||||
.sidebar-command kbd {
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
font-family: inherit;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
.sidebar-nav-item {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-bottom: 4px;
|
||||
padding: 0 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar-nav-item > span:last-child,
|
||||
.recent-link > span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-nav-item .semi-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--semi-color-text-2);
|
||||
}
|
||||
.app-sidebar .active,
|
||||
.app-sidebar .active:hover,
|
||||
.app-sidebar .active .semi-icon {
|
||||
background: var(--semi-color-primary-light-default);
|
||||
color: var(--semi-color-primary);
|
||||
}
|
||||
.sidebar-footer {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--semi-color-border);
|
||||
padding: 8px;
|
||||
}
|
||||
.recent-heading {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--semi-color-text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.recent-heading button {
|
||||
height: 28px;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.recent-item {
|
||||
height: 36px;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
}
|
||||
.recent-link {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
gap: 8px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
.recent-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
margin-right: 4px;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
}
|
||||
.recent-item:hover .recent-close,
|
||||
.recent-close:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.recent-close:disabled {
|
||||
cursor: default;
|
||||
opacity: 0;
|
||||
}
|
||||
.sidebar-collapse {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--semi-color-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.sidebar-collapse button {
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.is-collapsed .sidebar-user-trigger {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.is-collapsed .sidebar-nav {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.is-collapsed .sidebar-nav-item,
|
||||
.is-collapsed .recent-link,
|
||||
.is-collapsed .sidebar-collapse button {
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.sidebar-user-menu {
|
||||
min-width: 208px;
|
||||
max-width: 300px;
|
||||
padding: 4px;
|
||||
background: var(--semi-color-bg-3);
|
||||
box-shadow: var(--semi-shadow-elevated);
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
}
|
||||
.sidebar-user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sidebar-user-details span {
|
||||
font-size: 12px;
|
||||
color: var(--semi-color-text-2);
|
||||
}
|
||||
.sidebar-user-menu button,
|
||||
.command-items button {
|
||||
min-height: 36px;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.main-shell {
|
||||
padding-top: 36px;
|
||||
}
|
||||
.workspace-tools {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: var(--chat-space, 0px);
|
||||
z-index: 1002;
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
padding: 2px 16px;
|
||||
}
|
||||
.workspace-tools .semi-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
.main-shell .page-content {
|
||||
padding-top: 0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.workspace,
|
||||
.workspace.sidebar-collapsed {
|
||||
padding-left: 0;
|
||||
}
|
||||
.app-sidebar {
|
||||
display: none;
|
||||
}
|
||||
.workspace-tools {
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.workspace,
|
||||
.app-sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.settings-drawer .ai-settings-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.settings-drawer .ai-settings {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-drawer .workspace-preferences {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.settings-drawer .preferences-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.settings-drawer .preferences-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.settings-drawer .preferences-form > .semi-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
@@ -4,14 +4,12 @@ import {
|
||||
Banner,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Spin,
|
||||
Tag,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { api, displayValue, formatTime, patch, post } from "../api";
|
||||
import { api, displayValue, formatTime, post } from "../api";
|
||||
import type { Account, AccountUsage } from "../types";
|
||||
import { ModelSettingsPanel } from "../ai/ModelSettingsPanel";
|
||||
|
||||
const connectionLabels: Record<string, string> = {
|
||||
connected: "已连接",
|
||||
@@ -57,29 +55,9 @@ export function AccountPage({
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [preferences, setPreferences] = useState({
|
||||
display_name: "研究员",
|
||||
theme: "light",
|
||||
timezone: "Asia/Shanghai",
|
||||
page_size: 25,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (account) setEmail(account.email ?? "");
|
||||
}, [account?.email]);
|
||||
useEffect(() => {
|
||||
if (account)
|
||||
setPreferences({
|
||||
display_name: account.display_name,
|
||||
theme: account.theme,
|
||||
timezone: account.timezone,
|
||||
page_size: account.page_size,
|
||||
});
|
||||
}, [
|
||||
account?.display_name,
|
||||
account?.theme,
|
||||
account?.timezone,
|
||||
account?.page_size,
|
||||
]);
|
||||
if (!account)
|
||||
return (
|
||||
<div className="screen-center">
|
||||
@@ -161,29 +139,6 @@ export function AccountPage({
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<h1>个人信息</h1>
|
||||
<div className="inline-actions">
|
||||
<Button
|
||||
type="tertiary"
|
||||
disabled={!account.configured || blocked}
|
||||
loading={busy === "profile"}
|
||||
aria-label="刷新个人资料"
|
||||
onClick={() =>
|
||||
void action("profile", () => post("/account/refresh"))
|
||||
}
|
||||
>
|
||||
刷新资料
|
||||
</Button>
|
||||
<Button
|
||||
type="tertiary"
|
||||
aria-expanded={settingsOpen || !account.configured}
|
||||
onClick={() => setSettingsOpen(!settingsOpen)}
|
||||
>
|
||||
连接设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{account.connection_error && (
|
||||
<Banner
|
||||
type={
|
||||
@@ -251,6 +206,26 @@ export function AccountPage({
|
||||
有效期 {duration(account.session.total_seconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<Button
|
||||
type="tertiary"
|
||||
disabled={!account.configured || blocked}
|
||||
loading={busy === "profile"}
|
||||
aria-label="刷新个人资料"
|
||||
onClick={() =>
|
||||
void action("profile", () => post("/account/refresh"))
|
||||
}
|
||||
>
|
||||
刷新资料
|
||||
</Button>
|
||||
<Button
|
||||
type="tertiary"
|
||||
aria-expanded={settingsOpen || !account.configured}
|
||||
onClick={() => setSettingsOpen(!settingsOpen)}
|
||||
>
|
||||
连接设置
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{(settingsOpen || !account.configured) && (
|
||||
<section
|
||||
@@ -448,86 +423,6 @@ export function AccountPage({
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
<ModelSettingsPanel />
|
||||
<section className="account-section full-width">
|
||||
<div className="section-heading">
|
||||
<h2>工作空间偏好</h2>
|
||||
<span className="muted">
|
||||
更新于 {formatTime(account.last_synced_at, account.timezone)}
|
||||
</span>
|
||||
</div>
|
||||
<form
|
||||
className="preferences-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void action("preferences", () =>
|
||||
patch("/account/preferences", preferences),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="preferences-grid">
|
||||
<label>
|
||||
显示名称
|
||||
<Input
|
||||
aria-label="显示名称"
|
||||
value={preferences.display_name}
|
||||
onChange={(display_name) =>
|
||||
setPreferences({ ...preferences, display_name })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
主题
|
||||
<Select
|
||||
aria-label="主题"
|
||||
value={preferences.theme}
|
||||
optionList={[
|
||||
{ value: "light", label: "浅色" },
|
||||
{ value: "dark", label: "深色" },
|
||||
]}
|
||||
onChange={(theme) =>
|
||||
setPreferences({ ...preferences, theme: String(theme) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
时区
|
||||
<Input
|
||||
aria-label="时区"
|
||||
value={preferences.timezone}
|
||||
onChange={(timezone) =>
|
||||
setPreferences({ ...preferences, timezone })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
每页数量
|
||||
<Select
|
||||
aria-label="每页数量"
|
||||
value={preferences.page_size}
|
||||
optionList={[25, 50, 100].map((value) => ({
|
||||
value,
|
||||
label: `${value} 条`,
|
||||
}))}
|
||||
onChange={(page_size) =>
|
||||
setPreferences({
|
||||
...preferences,
|
||||
page_size: Number(page_size),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
theme="solid"
|
||||
loading={busy === "preferences"}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
保存偏好
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "../api";
|
||||
import type { Account, Job } from "../types";
|
||||
import type { PageContext, UIAction } from "../ai/types";
|
||||
import { FieldAvailability } from "../research/FieldAvailability";
|
||||
import "./dataset.css";
|
||||
|
||||
type Scope = {
|
||||
@@ -1237,6 +1238,13 @@ export function DatasetPage({
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{detail?.field && (
|
||||
<FieldAvailability
|
||||
key={`${detail.field}:${scopeQuery}`}
|
||||
field={detail.field}
|
||||
scope={scope}
|
||||
/>
|
||||
)}
|
||||
{conflict && (
|
||||
<Banner
|
||||
type="warning"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import { Banner, Button, Input } from "@douyinfe/semi-ui-19";
|
||||
import { post, formatNumber, formatTime } from "../api";
|
||||
import { PnlChart } from "../components/PnlChart";
|
||||
import type { SimulationSettings } from "../backtests/types";
|
||||
type Comparison = {
|
||||
window: { from: string; to: string } | null;
|
||||
different_settings: boolean;
|
||||
note: string;
|
||||
items: {
|
||||
alpha_id: string;
|
||||
expression: string;
|
||||
settings: SimulationSettings;
|
||||
metrics: Record<string, unknown>;
|
||||
observed_at: string;
|
||||
pnl: { date: string; value: number }[];
|
||||
}[];
|
||||
};
|
||||
export function ComparisonPanel({ baseline = "" }: { baseline?: string }) {
|
||||
const [ids, setIds] = useState(baseline);
|
||||
const [result, setResult] = useState<Comparison | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function compare() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setResult(
|
||||
await post("/research/compare", {
|
||||
alpha_ids: ids.split(/[,,\s]+/).filter(Boolean),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
setError((error as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="research-card">
|
||||
<h3>基线与候选比较</h3>
|
||||
<p>首个 Alpha 为基线,后续为候选。比较本地快照和共同日期窗口的 PnL。</p>
|
||||
<div className="research-toolbar">
|
||||
<Input
|
||||
aria-label="比较 Alpha 编号"
|
||||
value={ids}
|
||||
onChange={setIds}
|
||||
placeholder="基线 Alpha ID, 候选 Alpha ID"
|
||||
/>
|
||||
<Button loading={busy} onClick={() => void compare()}>
|
||||
比较
|
||||
</Button>
|
||||
</div>
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
{result && (
|
||||
<>
|
||||
<Banner
|
||||
type={result.different_settings ? "warning" : "info"}
|
||||
description={`${result.different_settings ? "存在不同市场或设置,请结合范围解读。" : ""}${result.window ? `共同窗口 ${result.window.from} 至 ${result.window.to}` : "没有共同 PnL 窗口"}。${result.note}`}
|
||||
/>
|
||||
<div className="research-comparison-grid">
|
||||
{result.items.map((item, index) => (
|
||||
<article key={item.alpha_id}>
|
||||
<h4>
|
||||
{index === 0 ? "基线" : "候选"} · {item.alpha_id}
|
||||
</h4>
|
||||
<code>{item.expression}</code>
|
||||
<p>
|
||||
{item.settings.region} / {item.settings.universe} / D
|
||||
{item.settings.delay} · {item.settings.neutralization} · decay{" "}
|
||||
{item.settings.decay}
|
||||
</p>
|
||||
<p>
|
||||
Sharpe {formatNumber(item.metrics.sharpe)} · Fitness{" "}
|
||||
{formatNumber(item.metrics.fitness)} · Turnover{" "}
|
||||
{formatNumber(item.metrics.turnover)}
|
||||
</p>
|
||||
<small>指标快照 {formatTime(item.observed_at)}</small>
|
||||
<PnlChart points={item.pnl} />
|
||||
<details>
|
||||
<summary>完整设置差异依据</summary>
|
||||
<pre>{JSON.stringify(item.settings, null, 2)}</pre>
|
||||
</details>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useState } from "react";
|
||||
import { Banner, Button, Checkbox, Tag, Toast } from "@douyinfe/semi-ui-19";
|
||||
import { post, formatTime } from "../api";
|
||||
import type { UIAction } from "../ai/types";
|
||||
import type { Experiment } from "./workspaceTypes";
|
||||
import { validationLabel } from "./workspaceTypes";
|
||||
|
||||
export function ExperimentView({
|
||||
experiment,
|
||||
onAction,
|
||||
}: {
|
||||
experiment: Experiment;
|
||||
onAction: (action: UIAction) => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const valid = experiment.candidates.filter(
|
||||
(c) => c.validation.status === "valid",
|
||||
);
|
||||
async function preview() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await post<{ preview_id: string }>(
|
||||
`/research/experiments/${experiment.id}/preview`,
|
||||
selected.length ? { candidate_ids: selected } : {},
|
||||
);
|
||||
onAction({
|
||||
type: "open_backtest_preview",
|
||||
preview_id: result.preview_id,
|
||||
nonce: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
Toast.error((error as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="research-card" aria-label="候选与来源">
|
||||
<div className="research-section-heading">
|
||||
<div>
|
||||
<h3>{experiment.name}</h3>
|
||||
<p>
|
||||
{formatTime(experiment.created_at)} · {experiment.candidates.length}{" "}
|
||||
个候选 · {valid.length} 个可预览
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={!valid.length}
|
||||
loading={busy}
|
||||
onClick={() => void preview()}
|
||||
>
|
||||
生成回测确认预览{selected.length ? `(${selected.length})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
<p>{experiment.hypothesis}</p>
|
||||
<Banner
|
||||
type="info"
|
||||
description="候选已保存为不可变研究记录。本地校验不保证平台可执行;在回测预览中确认后才开始模拟。"
|
||||
/>
|
||||
<div className="research-table-scroll">
|
||||
<table className="research-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>选择</th>
|
||||
<th>候选表达式</th>
|
||||
<th>市场与设置</th>
|
||||
<th>校验</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{experiment.candidates.map((candidate) => (
|
||||
<tr key={candidate.client_item_id}>
|
||||
<td>
|
||||
<Checkbox
|
||||
aria-label={`选择候选 ${candidate.client_item_id}`}
|
||||
disabled={candidate.validation.status !== "valid"}
|
||||
checked={selected.includes(candidate.client_item_id)}
|
||||
onChange={(event) =>
|
||||
setSelected((old) =>
|
||||
event.target.checked
|
||||
? [...old, candidate.client_item_id]
|
||||
: old.filter((id) => id !== candidate.client_item_id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<code>{candidate.expression}</code>
|
||||
<details>
|
||||
<summary>绑定与改动</summary>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
{
|
||||
bindings: candidate.bindings,
|
||||
changes: candidate.changes,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</details>
|
||||
</td>
|
||||
<td>
|
||||
{candidate.settings.region} / {candidate.settings.universe} /
|
||||
D{candidate.settings.delay}
|
||||
<br />
|
||||
{candidate.settings.neutralization} · decay{" "}
|
||||
{candidate.settings.decay}
|
||||
</td>
|
||||
<td>
|
||||
<Tag
|
||||
color={
|
||||
candidate.validation.status === "valid"
|
||||
? "green"
|
||||
: "orange"
|
||||
}
|
||||
>
|
||||
{validationLabel[candidate.validation.status]}
|
||||
</Tag>
|
||||
{(["syntax", "types", "availability"] as const).map((key) =>
|
||||
candidate.validation[key].map((issue, index) => (
|
||||
<p key={`${key}-${index}`}>
|
||||
{
|
||||
{
|
||||
syntax: "语法",
|
||||
types: "类型",
|
||||
availability: "可用性",
|
||||
}[key]
|
||||
}
|
||||
:{issue}
|
||||
</p>
|
||||
)),
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="research-lineage">
|
||||
<strong>研究来源</strong>
|
||||
<span>实验 {experiment.id}</span>
|
||||
{experiment.evidence.template?.id && (
|
||||
<Button
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_template",
|
||||
asset_id: experiment.evidence.template!.id,
|
||||
version: experiment.evidence.template!.version,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
模板 v{experiment.evidence.template.version}
|
||||
</Button>
|
||||
)}
|
||||
{experiment.inputs.map((input) => (
|
||||
<Button
|
||||
key={input.id}
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_research_input",
|
||||
input_id: input.id,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{input.dataset_id} · {input.scope.region}/{input.scope.universe}/D
|
||||
{input.scope.delay}
|
||||
</Button>
|
||||
))}
|
||||
{experiment.parents.map((parent) => (
|
||||
<Button
|
||||
key={`${parent.kind}:${parent.id}`}
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
onAction(
|
||||
parent.kind === "alpha"
|
||||
? {
|
||||
type: "open_alpha",
|
||||
alpha_id: parent.id,
|
||||
nonce: Date.now(),
|
||||
}
|
||||
: {
|
||||
type: "open_experiment",
|
||||
experiment_id: parent.id,
|
||||
nonce: Date.now(),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
父来源:{parent.id}
|
||||
</Button>
|
||||
))}
|
||||
{experiment.backtest_run_ids.map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
onAction({ type: "open_backtest", run_id: id, nonce: Date.now() })
|
||||
}
|
||||
>
|
||||
查看关联回测
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Banner, Button } from "@douyinfe/semi-ui-19";
|
||||
import { api, post, queryString, formatTime } from "../api";
|
||||
type Scope = {
|
||||
instrument_type: string;
|
||||
region: string;
|
||||
universe: string;
|
||||
delay: number;
|
||||
};
|
||||
type Snapshot = {
|
||||
content: { status?: string; reason?: string; items?: Scope[] };
|
||||
fetched_at: string | null;
|
||||
};
|
||||
export function FieldAvailability({
|
||||
field,
|
||||
scope,
|
||||
}: {
|
||||
field: string;
|
||||
scope: Scope;
|
||||
}) {
|
||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const query = queryString(scope);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api<Snapshot>(
|
||||
`/catalog/field-availability/${encodeURIComponent(field)}?${query}`,
|
||||
)
|
||||
.then((value) => {
|
||||
if (live) setSnapshot(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (live) setError(error.message);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [field, query]);
|
||||
async function refresh() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setSnapshot(
|
||||
await post("/catalog/field-availability/refresh", {
|
||||
field_id: field,
|
||||
scope,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
setError((error as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<h3>字段可用范围</h3>
|
||||
<p>{formatTime(snapshot?.fetched_at)}</p>
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
{snapshot?.content.items?.length ? (
|
||||
snapshot.content.items.map((item, index) => (
|
||||
<p key={index}>
|
||||
{item.instrument_type} · {item.region} / {item.universe} / D
|
||||
{item.delay}
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<p>
|
||||
{snapshot?.content.reason ||
|
||||
"尚未取得可用性证据,请查询平台。未知不代表可用。"}
|
||||
</p>
|
||||
)}
|
||||
<Button loading={busy} onClick={() => void refresh()}>
|
||||
查询字段可用范围
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { ResearchSelect } from "./ResearchSelect";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
TextArea,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { api, patch, post, formatTime } from "../api";
|
||||
import "./workspace.css";
|
||||
type Operator = {
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
definition: string;
|
||||
example: string;
|
||||
parameters: unknown;
|
||||
scope: string[];
|
||||
local: { note: string; favorite: boolean; version: number };
|
||||
};
|
||||
type Results = {
|
||||
items: Operator[];
|
||||
total: number;
|
||||
categories: string[];
|
||||
fetched_at: string | null;
|
||||
};
|
||||
export function OperatorsPage({ active }: { active: boolean }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [favorite, setFavorite] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [result, setResult] = useState<Results>({
|
||||
items: [],
|
||||
total: 0,
|
||||
categories: [],
|
||||
fetched_at: null,
|
||||
});
|
||||
const [selected, setSelected] = useState<Operator | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function load() {
|
||||
setResult(
|
||||
await api(
|
||||
`/catalog/operators?${new URLSearchParams({ q: query, category, favorite: String(favorite), limit: "25", offset: String(offset) })}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
async function task(operation: () => Promise<void>) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
setError((error as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (active) void task(load);
|
||||
}, [active, query, category, favorite, offset]);
|
||||
return (
|
||||
<div className="research-workspace">
|
||||
<header className="research-page-heading">
|
||||
<div>
|
||||
<h2>算子库</h2>
|
||||
<p>
|
||||
平台定义只读,本地收藏和研究备注独立保存。同步时间:
|
||||
{formatTime(result.fetched_at)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
theme="solid"
|
||||
loading={busy}
|
||||
onClick={() =>
|
||||
void task(async () => {
|
||||
await post("/catalog/operators/refresh");
|
||||
await load();
|
||||
})
|
||||
}
|
||||
>
|
||||
同步算子目录
|
||||
</Button>
|
||||
</header>
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
<div className="research-toolbar">
|
||||
<Input
|
||||
aria-label="搜索算子"
|
||||
placeholder="名称、参数或描述"
|
||||
value={query}
|
||||
onChange={(value) => {
|
||||
setQuery(value);
|
||||
setOffset(0);
|
||||
}}
|
||||
/>
|
||||
<ResearchSelect
|
||||
label="算子分类"
|
||||
value={category}
|
||||
optionList={[
|
||||
{ value: "", label: "全部分类" },
|
||||
...result.categories.map((value) => ({ value, label: value })),
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setCategory(value as string);
|
||||
setOffset(0);
|
||||
}}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={favorite}
|
||||
onChange={(event) => {
|
||||
setFavorite(!!event.target.checked);
|
||||
setOffset(0);
|
||||
}}
|
||||
>
|
||||
只看收藏
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="research-operator-layout">
|
||||
<section className="research-card">
|
||||
<table className="research-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>算子</th>
|
||||
<th>分类</th>
|
||||
<th>定义</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.map((item) => (
|
||||
<tr key={item.name}>
|
||||
<td>
|
||||
<Button
|
||||
theme="borderless"
|
||||
onClick={() => setSelected(item)}
|
||||
>
|
||||
{item.local.favorite ? "已收藏 · " : ""}
|
||||
{item.name}
|
||||
</Button>
|
||||
</td>
|
||||
<td>{item.category}</td>
|
||||
<td>
|
||||
<code>{item.definition}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!result.items.length && (
|
||||
<p>暂无算子。连接平台并同步目录,或调整筛选条件。</p>
|
||||
)}
|
||||
<div className="research-toolbar">
|
||||
<span>共 {result.total} 个</span>
|
||||
<Button
|
||||
disabled={!offset}
|
||||
onClick={() => setOffset(Math.max(0, offset - 25))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
disabled={offset + 25 >= result.total}
|
||||
onClick={() => setOffset(offset + 25)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{selected && (
|
||||
<section className="research-card">
|
||||
<h3>{selected.name}</h3>
|
||||
<p>{selected.description}</p>
|
||||
<code>{selected.definition}</code>
|
||||
{selected.example && <pre>{selected.example}</pre>}
|
||||
{!!selected.parameters && (
|
||||
<pre>{JSON.stringify(selected.parameters, null, 2)}</pre>
|
||||
)}
|
||||
<p>适用:{selected.scope?.join("、") || "平台未提供"}</p>
|
||||
<label>
|
||||
研究备注
|
||||
<TextArea
|
||||
aria-label="算子研究备注"
|
||||
value={selected.local.note}
|
||||
onChange={(note) =>
|
||||
setSelected({
|
||||
...selected,
|
||||
local: { ...selected.local, note },
|
||||
})
|
||||
}
|
||||
autosize={{ minRows: 4, maxRows: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={selected.local.favorite}
|
||||
onChange={(event) =>
|
||||
setSelected({
|
||||
...selected,
|
||||
local: {
|
||||
...selected.local,
|
||||
favorite: !!event.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
收藏算子
|
||||
</Checkbox>
|
||||
<div className="research-toolbar">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void task(async () => {
|
||||
const result = await patch<{ version: number }>(
|
||||
`/catalog/operators/${encodeURIComponent(selected.name)}/research`,
|
||||
selected.local,
|
||||
);
|
||||
setSelected({
|
||||
...selected,
|
||||
local: { ...selected.local, version: result.version },
|
||||
});
|
||||
await load();
|
||||
})
|
||||
}
|
||||
>
|
||||
保存本地备注
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useId, type ComponentProps } from "react";
|
||||
import { Select } from "@douyinfe/semi-ui-19";
|
||||
/** Semi Select exposes its accessible name through aria-labelledby. */
|
||||
export function ResearchSelect({
|
||||
label,
|
||||
...props
|
||||
}: ComponentProps<typeof Select> & { label: string }) {
|
||||
const id = useId();
|
||||
return (
|
||||
<>
|
||||
<span id={id} className="research-sr-only">
|
||||
{label}
|
||||
</span>
|
||||
<Select {...props} aria-labelledby={id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Button } from "@douyinfe/semi-ui-19";
|
||||
import type { ToolCardProps } from "../ai/types";
|
||||
export function ResearchToolCard({ call, onAction }: ToolCardProps) {
|
||||
const result = call.result || {};
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
{typeof result.name === "string" ? result.name : "研究资料"}
|
||||
{typeof result.version === "number" ? ` · v${result.version}` : ""}
|
||||
</p>
|
||||
{typeof result.id === "string" && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction(
|
||||
result.content
|
||||
? {
|
||||
type: "open_template",
|
||||
asset_id: result.id as string,
|
||||
version: result.version as number,
|
||||
nonce: Date.now(),
|
||||
}
|
||||
: {
|
||||
type: "open_experiment",
|
||||
experiment_id: result.id as string,
|
||||
nonce: Date.now(),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
打开研究记录
|
||||
</Button>
|
||||
)}
|
||||
<details>
|
||||
<summary>查看资料与校验依据</summary>
|
||||
<pre>{JSON.stringify(result, null, 2)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import { ResearchSelect } from "./ResearchSelect";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
TextArea,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { api, post, formatTime } from "../api";
|
||||
import type { PageContext, UIAction } from "../ai/types";
|
||||
import { initialSettings, type SimulationSettings } from "../backtests/types";
|
||||
import {
|
||||
blankTemplate,
|
||||
type Asset,
|
||||
type Experiment,
|
||||
type InputSnapshot,
|
||||
type Template,
|
||||
} from "./workspaceTypes";
|
||||
import { TemplateEditor } from "./TemplateEditor";
|
||||
import { ExperimentView } from "./ExperimentView";
|
||||
import { ComparisonPanel } from "./ComparisonPanel";
|
||||
import "./workspace.css";
|
||||
|
||||
type ImportResult = {
|
||||
templates: Template[];
|
||||
digest: string;
|
||||
conflicts: unknown[];
|
||||
errors: unknown[];
|
||||
differences?: unknown[];
|
||||
};
|
||||
export function ResearchWorkspace({
|
||||
page,
|
||||
active,
|
||||
action,
|
||||
onAction,
|
||||
onContext,
|
||||
}: {
|
||||
page: "templates" | "variants";
|
||||
active: boolean;
|
||||
action: UIAction | null;
|
||||
onAction: (action: UIAction) => void;
|
||||
onContext: (context: PageContext) => void;
|
||||
}) {
|
||||
const processedAction = useRef<number | null>(null);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [inputs, setInputs] = useState<InputSnapshot[]>([]);
|
||||
const [inputIds, setInputIds] = useState<string[]>([]);
|
||||
const [template, setTemplate] = useState<Template>(blankTemplate);
|
||||
const [asset, setAsset] = useState<Asset | null>(null);
|
||||
const [hypothesis, setHypothesis] = useState("");
|
||||
const [parent, setParent] = useState("");
|
||||
const [method, setMethod] = useState("structure");
|
||||
const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
|
||||
const [neutralizations, setNeutralizations] = useState<string[]>([]);
|
||||
const [mode, setMode] = useState("all");
|
||||
const [limit, setLimit] = useState(100);
|
||||
const [seed, setSeed] = useState(0);
|
||||
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
||||
const [history, setHistory] = useState<
|
||||
{ id: string; name: string; created_at: string; total: number }[]
|
||||
>([]);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [assetPage, setAssetPage] = useState(0);
|
||||
const [historyPage, setHistoryPage] = useState(0);
|
||||
const [assetTotal, setAssetTotal] = useState(0);
|
||||
const [historyTotal, setHistoryTotal] = useState(0);
|
||||
const refreshSequence = useRef(0);
|
||||
const [importText, setImportText] = useState("");
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const selectedInputs = inputs.filter((input) => inputIds.includes(input.id));
|
||||
const dirty =
|
||||
!asset || JSON.stringify(template) !== JSON.stringify(asset.content);
|
||||
async function task(label: string, action: () => Promise<void>) {
|
||||
setBusy(label);
|
||||
setError("");
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
setError((error as Error).message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
async function refresh() {
|
||||
const sequence = ++refreshSequence.current;
|
||||
const [nextAssets, nextInputs, nextHistory] = await Promise.all([
|
||||
api<{ items: Asset[]; total: number }>(
|
||||
`/research/assets?kind=template&limit=25&offset=${assetPage * 25}&q=${encodeURIComponent(search)}`,
|
||||
),
|
||||
api<{ items: InputSnapshot[] }>("/research/inputs"),
|
||||
api<{ items: typeof history; total: number }>(
|
||||
`/research/experiments?limit=25&offset=${historyPage * 25}`,
|
||||
),
|
||||
]);
|
||||
if (sequence !== refreshSequence.current) return;
|
||||
setAssetTotal(nextAssets.total);
|
||||
setHistoryTotal(nextHistory.total);
|
||||
setAssets(nextAssets.items);
|
||||
setInputs(nextInputs.items);
|
||||
setHistory(nextHistory.items);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (active) void task("读取研究素材", refresh);
|
||||
}, [active, search, assetPage, historyPage]);
|
||||
useEffect(() => {
|
||||
if (active)
|
||||
onContext({
|
||||
page,
|
||||
research_asset_id: asset?.id,
|
||||
research_experiment_id: experiment?.id,
|
||||
alpha_id: parent.split(/[,,\s]+/)[0] || undefined,
|
||||
template_input_id: inputIds.length === 1 ? inputIds[0] : undefined,
|
||||
});
|
||||
}, [active, page, asset?.id, experiment?.id, parent, inputIds, onContext]);
|
||||
useEffect(() => {
|
||||
if (!active || !action || processedAction.current === action.nonce) return;
|
||||
processedAction.current = action.nonce;
|
||||
if (action.type === "open_experiment")
|
||||
void task("读取实验", async () =>
|
||||
setExperiment(
|
||||
await api(`/research/experiments/${action.experiment_id}`),
|
||||
),
|
||||
);
|
||||
if (action.type === "open_template")
|
||||
void task("读取模板", async () => {
|
||||
const next = await api<Asset>(
|
||||
`/research/assets/${action.asset_id}${action.version ? `?version=${action.version}` : ""}`,
|
||||
);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
});
|
||||
if (action.type === "open_variant") {
|
||||
setParent(action.alpha_id);
|
||||
setMethod("structure");
|
||||
}
|
||||
}, [active, action]);
|
||||
function selectInputs(ids: string[]) {
|
||||
setInputIds(ids);
|
||||
const first = inputs.find((item) => item.id === ids[0]);
|
||||
if (first)
|
||||
setSettings((old) => ({
|
||||
...old,
|
||||
region: first.scope.region,
|
||||
universe: first.scope.universe,
|
||||
delay: first.scope.delay,
|
||||
}));
|
||||
}
|
||||
async function refreshSettings() {
|
||||
const data = await post<{
|
||||
content: {
|
||||
items: {
|
||||
region: string;
|
||||
universe: string;
|
||||
delay: number;
|
||||
neutralizations: string[];
|
||||
}[];
|
||||
};
|
||||
}>("/catalog/setting-options/refresh");
|
||||
const options = data.content.items
|
||||
.filter(
|
||||
(row) =>
|
||||
row.region === settings.region &&
|
||||
row.universe === settings.universe &&
|
||||
row.delay === settings.delay,
|
||||
)
|
||||
.flatMap((row) => row.neutralizations);
|
||||
setNeutralizations([...new Set(options)]);
|
||||
Toast.success("合法设置已同步");
|
||||
}
|
||||
async function save() {
|
||||
const next = asset
|
||||
? await api<Asset>(`/research/assets/${asset.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
kind: "template",
|
||||
version: asset.version,
|
||||
content: template,
|
||||
}),
|
||||
})
|
||||
: await post<Asset>("/research/assets", {
|
||||
kind: "template",
|
||||
content: template,
|
||||
});
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
await refresh();
|
||||
Toast.success(`已保存模板 v${next.version}`);
|
||||
}
|
||||
async function generate() {
|
||||
const next = await post<Asset>("/research/generate", {
|
||||
name: template.name,
|
||||
hypothesis,
|
||||
input_ids: inputIds,
|
||||
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
|
||||
method: page === "variants" ? "structure" : "template",
|
||||
});
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
await refresh();
|
||||
}
|
||||
async function expand() {
|
||||
if (dirty) throw new Error("请先保存模板版本,再生成可追溯候选");
|
||||
const next = await post<Experiment>("/research/experiments", {
|
||||
asset_id: asset!.id,
|
||||
version: asset!.version,
|
||||
input_ids: inputIds,
|
||||
hypothesis,
|
||||
settings,
|
||||
mode,
|
||||
limit,
|
||||
seed,
|
||||
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
|
||||
});
|
||||
setExperiment(next);
|
||||
await refresh();
|
||||
}
|
||||
const combination = Object.values(template.variables)
|
||||
.reduce(
|
||||
(size, variable) =>
|
||||
size * BigInt(new Set(variable.values.map(String)).size),
|
||||
1n,
|
||||
)
|
||||
.toString();
|
||||
return (
|
||||
<div className="research-workspace">
|
||||
<header className="research-page-heading">
|
||||
<div>
|
||||
<h2>{page === "templates" ? "模板工坊" : "Alpha 变体"}</h2>
|
||||
<p>
|
||||
{page === "templates"
|
||||
? "从固定输入生成候选,保存每一次模板版本与研究依据。"
|
||||
: "围绕已有 Alpha 研究结构、字段与市场设置的变化。"}
|
||||
</p>
|
||||
</div>
|
||||
<Button loading={!!busy} onClick={() => void task("刷新", refresh)}>
|
||||
刷新素材
|
||||
</Button>
|
||||
</header>
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
<div className="research-layout">
|
||||
<aside className="research-library">
|
||||
<div className="research-section-heading">
|
||||
<h3>模板库</h3>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setAsset(null);
|
||||
setTemplate(blankTemplate());
|
||||
}}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
aria-label="搜索模板"
|
||||
placeholder="搜索名称"
|
||||
value={search}
|
||||
onChange={(value) => {
|
||||
setSearch(value);
|
||||
setAssetPage(0);
|
||||
}}
|
||||
/>
|
||||
{assets.map((item) => (
|
||||
<button
|
||||
className={`research-library-item ${asset?.id === item.id ? "selected" : ""}`}
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
void task("读取模板", async () => {
|
||||
const next = await api<Asset>(`/research/assets/${item.id}`);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
})
|
||||
}
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<span>v{item.version}</span>
|
||||
</button>
|
||||
))}
|
||||
{!assets.length && <p>还没有模板。新建或导入一个模板开始研究。</p>}
|
||||
<div className="research-toolbar">
|
||||
<small>共 {assetTotal} 个模板</small>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!assetPage}
|
||||
onClick={() => setAssetPage(assetPage - 1)}
|
||||
>
|
||||
上一页模板
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={(assetPage + 1) * 25 >= assetTotal}
|
||||
onClick={() => setAssetPage(assetPage + 1)}
|
||||
>
|
||||
下一页模板
|
||||
</Button>
|
||||
</div>
|
||||
<h3>生成记录</h3>
|
||||
{history.map((item) => (
|
||||
<button
|
||||
className="research-library-item"
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
void task("读取实验", async () =>
|
||||
setExperiment(await api(`/research/experiments/${item.id}`)),
|
||||
)
|
||||
}
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<span>
|
||||
{item.total} 个候选 · {formatTime(item.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="research-toolbar">
|
||||
<small>共 {historyTotal} 次生成</small>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!historyPage}
|
||||
onClick={() => setHistoryPage(historyPage - 1)}
|
||||
>
|
||||
上一页记录
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={(historyPage + 1) * 25 >= historyTotal}
|
||||
onClick={() => setHistoryPage(historyPage + 1)}
|
||||
>
|
||||
下一页记录
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="research-main">
|
||||
<section className="research-card">
|
||||
<h3>研究输入与假设</h3>
|
||||
{page === "variants" && (
|
||||
<>
|
||||
<div className="research-methods">
|
||||
<Button
|
||||
theme={method === "structure" ? "solid" : "light"}
|
||||
onClick={() => setMethod("structure")}
|
||||
>
|
||||
结构与字段变体
|
||||
</Button>
|
||||
<Button
|
||||
theme={method === "settings" ? "solid" : "light"}
|
||||
onClick={() => setMethod("settings")}
|
||||
>
|
||||
市场与设置变体
|
||||
</Button>
|
||||
</div>
|
||||
<p>
|
||||
{method === "structure"
|
||||
? "神笔马良:保留种子与改动依据,研究新的表达式。"
|
||||
: "缘分一道桥:保持表达式,为各目标市场重新固定输入后检查可用范围。"}
|
||||
</p>
|
||||
<label>
|
||||
种子 Alpha
|
||||
<Input
|
||||
aria-label="种子 Alpha"
|
||||
value={parent}
|
||||
onChange={setParent}
|
||||
placeholder="已同步的 Alpha ID"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
固定研究输入
|
||||
<ResearchSelect
|
||||
multiple
|
||||
filter
|
||||
label="固定研究输入"
|
||||
value={inputIds}
|
||||
optionList={inputs.map((input) => ({
|
||||
value: input.id,
|
||||
label: `${input.dataset_id} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay} · ${input.field_ids.length} 字段 · ${input.id.slice(0, 8)}`,
|
||||
}))}
|
||||
onChange={(value) => selectInputs(value as string[])}
|
||||
/>
|
||||
</label>
|
||||
<p className="research-hint">
|
||||
在数据目录中保存字段选择。跨数据集分别关联输入,跨市场使用目标范围的独立输入。
|
||||
</p>
|
||||
<label>
|
||||
本次研究假设
|
||||
<TextArea
|
||||
aria-label="本次研究假设"
|
||||
value={hypothesis}
|
||||
onChange={setHypothesis}
|
||||
autosize={{ minRows: 2, maxRows: 5 }}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
{page !== "variants" || method === "structure" ? (
|
||||
<section className="research-card">
|
||||
<div className="research-section-heading">
|
||||
<h3>
|
||||
编辑与展开{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}
|
||||
onChange={setTemplate}
|
||||
inputs={selectedInputs}
|
||||
/>
|
||||
{asset && (
|
||||
<div className="research-toolbar">
|
||||
<span>版本记录</span>
|
||||
<ResearchSelect
|
||||
label="模板历史版本"
|
||||
value={asset.version}
|
||||
optionList={Array.from(
|
||||
{
|
||||
length:
|
||||
assets.find((item) => item.id === asset.id)
|
||||
?.version || asset.version,
|
||||
},
|
||||
(_, i) => ({ value: i + 1, label: `v${i + 1}` }),
|
||||
)}
|
||||
onChange={(version) =>
|
||||
void task("读取历史版本", async () => {
|
||||
const next = await api<Asset>(
|
||||
`/research/assets/${asset.id}?version=${version}`,
|
||||
);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAsset(null);
|
||||
setTemplate({
|
||||
...template,
|
||||
name: `${template.name}(副本)`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
另存新模板
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="research-toolbar">
|
||||
<Button
|
||||
disabled={!!busy}
|
||||
onClick={() => void task("保存模板", save)}
|
||||
>
|
||||
保存模板版本
|
||||
</Button>
|
||||
<span>组合规模:{combination}</span>
|
||||
</div>
|
||||
<div className="research-form-grid">
|
||||
<label>
|
||||
展开方式
|
||||
<ResearchSelect
|
||||
label="展开方式"
|
||||
value={mode}
|
||||
optionList={[
|
||||
{ value: "all", label: "全组合" },
|
||||
{ value: "random", label: "可复现随机采样" },
|
||||
]}
|
||||
onChange={(value) => setMode(value as string)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
候选数量上限
|
||||
<InputNumber
|
||||
aria-label="候选数量上限"
|
||||
value={limit}
|
||||
min={1}
|
||||
max={10000}
|
||||
onChange={(value) => setLimit(Number(value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
随机种子
|
||||
<InputNumber
|
||||
aria-label="随机种子"
|
||||
value={seed}
|
||||
onChange={(value) => setSeed(Number(value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="research-section-heading">
|
||||
<h4>模拟设置</h4>
|
||||
<Button
|
||||
disabled={!inputIds.length || !!busy}
|
||||
onClick={() => void task("同步合法设置", refreshSettings)}
|
||||
>
|
||||
同步合法设置
|
||||
</Button>
|
||||
</div>
|
||||
<p>
|
||||
{settings.region || "未选择市场"} /{" "}
|
||||
{settings.universe || "未选择股票池"} / D{settings.delay}
|
||||
(来自固定输入)
|
||||
</p>
|
||||
<div className="research-form-grid">
|
||||
<label>
|
||||
中性化
|
||||
<Input
|
||||
list={`${page}-neutralizations`}
|
||||
aria-label="中性化"
|
||||
value={settings.neutralization}
|
||||
onChange={(neutralization) =>
|
||||
setSettings({ ...settings, neutralization })
|
||||
}
|
||||
/>
|
||||
<datalist id={`${page}-neutralizations`}>
|
||||
{neutralizations.map((value) => (
|
||||
<option key={value} value={value} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Decay
|
||||
<InputNumber
|
||||
aria-label="Decay"
|
||||
min={0}
|
||||
value={settings.decay}
|
||||
onChange={(decay) =>
|
||||
setSettings({ ...settings, decay: Number(decay) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Truncation
|
||||
<InputNumber
|
||||
aria-label="Truncation"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={settings.truncation}
|
||||
onChange={(truncation) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
truncation: Number(truncation),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="research-form-grid">
|
||||
{(["pasteurization", "nanHandling", "maxTrade"] as const).map(
|
||||
(key) => (
|
||||
<label key={key}>
|
||||
{key}
|
||||
<ResearchSelect
|
||||
label={key}
|
||||
value={settings[key]}
|
||||
optionList={["ON", "OFF"].map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
}))}
|
||||
onChange={(value) =>
|
||||
setSettings({ ...settings, [key]: value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={dirty || !inputIds.length || !hypothesis || !!busy}
|
||||
onClick={() => void task("生成候选", expand)}
|
||||
>
|
||||
保存候选研究记录
|
||||
</Button>
|
||||
</section>
|
||||
) : (
|
||||
<section className="research-card">
|
||||
<h3>目标范围检查</h3>
|
||||
<p>
|
||||
保留种子表达式和模拟参数,按各目标输入的市场、股票池与延迟生成设置变体。局部变量不会当作字段;无法核实的字段不会被跳过。
|
||||
</p>
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={!parent || !inputIds.length || !!busy}
|
||||
onClick={() =>
|
||||
void task("检查设置变体", async () => {
|
||||
setExperiment(
|
||||
await post("/research/variants/settings", {
|
||||
alpha_id: parent,
|
||||
input_ids: inputIds,
|
||||
...(hypothesis ? { hypothesis } : {}),
|
||||
}),
|
||||
);
|
||||
await refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
生成设置变体
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
{experiment && (
|
||||
<ExperimentView
|
||||
key={experiment.id}
|
||||
experiment={experiment}
|
||||
onAction={onAction}
|
||||
/>
|
||||
)}
|
||||
{page === "templates" && (
|
||||
<details className="research-card">
|
||||
<summary>导入旧模板</summary>
|
||||
<p>
|
||||
粘贴 cnhk 模板 JSON
|
||||
或模板数组。先预览转换结果,同名模板不会自动覆盖。
|
||||
</p>
|
||||
<TextArea
|
||||
aria-label="导入模板 JSON"
|
||||
value={importText}
|
||||
onChange={(text) => {
|
||||
setImportText(text);
|
||||
setImportResult(null);
|
||||
}}
|
||||
autosize={{ minRows: 4, maxRows: 12 }}
|
||||
/>
|
||||
<Button
|
||||
disabled={!!busy || !importText}
|
||||
onClick={() =>
|
||||
void task("预览导入", async () => {
|
||||
const data = JSON.parse(importText);
|
||||
setImportResult(
|
||||
await post("/research/templates/import-preview", {
|
||||
templates: Array.isArray(data) ? data : [data],
|
||||
}),
|
||||
);
|
||||
})
|
||||
}
|
||||
>
|
||||
预览导入差异
|
||||
</Button>
|
||||
{importResult && (
|
||||
<>
|
||||
<pre>{JSON.stringify(importResult, null, 2)}</pre>
|
||||
<Button
|
||||
disabled={
|
||||
!!busy ||
|
||||
!!importResult.errors?.length ||
|
||||
!!importResult.conflicts?.length
|
||||
}
|
||||
onClick={() =>
|
||||
void task("导入模板", async () => {
|
||||
await post("/research/templates/import", {
|
||||
templates: importResult.templates,
|
||||
digest: importResult.digest,
|
||||
});
|
||||
setImportResult(null);
|
||||
setImportText("");
|
||||
await refresh();
|
||||
Toast.success("导入完成");
|
||||
})
|
||||
}
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
{page === "variants" && (
|
||||
<ComparisonPanel key={parent} baseline={parent} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,16 @@ import type { UIAction } from "../ai/types";
|
||||
import "./style.css";
|
||||
|
||||
export const sourceLabel = (kind: string) =>
|
||||
({ chatbox: "Chatbox 研究", manual: "手工研究", ai: "AI 研究(历史)" })[
|
||||
kind
|
||||
] ?? kind;
|
||||
({
|
||||
chatbox: "Chatbox 研究",
|
||||
manual: "手工研究",
|
||||
ai: "AI 研究(历史)",
|
||||
template: "模板研究",
|
||||
variant: "Alpha 变体",
|
||||
feature: "特征工程",
|
||||
pipeline: "研究流水线",
|
||||
quantflow: "QuantFlow",
|
||||
})[kind] ?? kind;
|
||||
|
||||
export function SourceDetails({
|
||||
source,
|
||||
@@ -25,6 +32,22 @@ export function SourceDetails({
|
||||
<p>来源引用:{source.reference}</p>
|
||||
)}
|
||||
<div className="inline-actions">
|
||||
{source.research_id &&
|
||||
["template", "variant", "feature", "pipeline", "quantflow"].includes(
|
||||
source.kind,
|
||||
) && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_experiment",
|
||||
experiment_id: source.research_id!,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
查看研究实验
|
||||
</Button>
|
||||
)}
|
||||
{source.kind === "chatbox" && source.reference && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { ResearchSelect } from "./ResearchSelect";
|
||||
import { Button, Input, TextArea } from "@douyinfe/semi-ui-19";
|
||||
import type { Template, Variable, InputSnapshot } from "./workspaceTypes";
|
||||
const kinds = {
|
||||
field: "字段",
|
||||
operator: "算子",
|
||||
integer: "整数",
|
||||
number: "数值",
|
||||
group: "分组",
|
||||
string: "字符串",
|
||||
fragment: "表达式片段",
|
||||
};
|
||||
export function TemplateEditor({
|
||||
value,
|
||||
onChange,
|
||||
inputs,
|
||||
}: {
|
||||
value: Template;
|
||||
onChange: (value: Template) => void;
|
||||
inputs: InputSnapshot[];
|
||||
}) {
|
||||
function expression(text: string) {
|
||||
const names = [
|
||||
...new Set(
|
||||
[...text.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map(
|
||||
(match) => match[1],
|
||||
),
|
||||
),
|
||||
];
|
||||
onChange({
|
||||
...value,
|
||||
expression: text,
|
||||
variables: Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
value.variables[name] || {
|
||||
kind: "field",
|
||||
field_type: "MATRIX",
|
||||
values: [],
|
||||
},
|
||||
]),
|
||||
),
|
||||
});
|
||||
}
|
||||
function variable(name: string, next: Variable) {
|
||||
onChange({ ...value, variables: { ...value.variables, [name]: next } });
|
||||
}
|
||||
return (
|
||||
<div className="research-editor">
|
||||
<div className="research-form-grid">
|
||||
<label>
|
||||
模板名称
|
||||
<Input
|
||||
aria-label="模板名称"
|
||||
value={value.name}
|
||||
onChange={(name) => onChange({ ...value, name })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
类别
|
||||
<ResearchSelect
|
||||
label="模板类别"
|
||||
value={value.category}
|
||||
optionList={[
|
||||
{ value: "template", label: "研究模板" },
|
||||
{ value: "fragment", label: "算子组合片段" },
|
||||
]}
|
||||
onChange={(category) =>
|
||||
onChange({ ...value, category: category as Template["category"] })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
研究解释
|
||||
<TextArea
|
||||
aria-label="模板研究解释"
|
||||
value={value.description}
|
||||
onChange={(description) => onChange({ ...value, description })}
|
||||
autosize={{ minRows: 2, maxRows: 5 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
表达式模板
|
||||
<TextArea
|
||||
aria-label="表达式模板"
|
||||
className="research-code-editor"
|
||||
value={value.expression}
|
||||
onChange={expression}
|
||||
autosize={{ minRows: 3, maxRows: 10 }}
|
||||
placeholder="rank({field})"
|
||||
/>
|
||||
</label>
|
||||
<p className="research-hint">
|
||||
使用 {"{name}"}{" "}
|
||||
声明变量;重复占位符共享同一个取值。组合片段只复用表达式,不修改平台算子定义。
|
||||
</p>
|
||||
{Object.entries(value.variables).map(([name, item]) => (
|
||||
<div className="research-variable" key={name}>
|
||||
<strong>{`{${name}}`}</strong>
|
||||
<ResearchSelect
|
||||
label={`${name} 变量类型`}
|
||||
value={item.kind}
|
||||
optionList={Object.entries(kinds).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
onChange={(kind) =>
|
||||
variable(name, {
|
||||
kind: kind as Variable["kind"],
|
||||
values: [],
|
||||
...(kind === "field" ? { field_type: "MATRIX" } : {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{item.kind === "field" && (
|
||||
<ResearchSelect
|
||||
label={`${name} 字段类型`}
|
||||
value={item.field_type}
|
||||
optionList={["MATRIX", "VECTOR", "GROUP"].map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
}))}
|
||||
onChange={(field_type) =>
|
||||
variable(name, {
|
||||
...item,
|
||||
field_type: field_type as Variable["field_type"],
|
||||
values: [],
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<TextArea
|
||||
aria-label={`${name} 候选取值`}
|
||||
placeholder={
|
||||
item.kind === "fragment"
|
||||
? "每行一个完整表达式片段"
|
||||
: "每行一个取值"
|
||||
}
|
||||
value={item.values.join("\n")}
|
||||
autosize={{ minRows: 2, maxRows: 6 }}
|
||||
onChange={(text) =>
|
||||
variable(name, {
|
||||
...item,
|
||||
values: text
|
||||
.split("\n")
|
||||
.map((v) =>
|
||||
["integer", "number"].includes(item.kind) && v.trim() !== ""
|
||||
? Number(v)
|
||||
: v,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{item.kind === "field" && (
|
||||
<Button
|
||||
disabled={!inputs.length}
|
||||
onClick={() =>
|
||||
variable(name, {
|
||||
...item,
|
||||
values: [
|
||||
...new Set(
|
||||
inputs.flatMap((input) =>
|
||||
input.field_ids.filter(
|
||||
(id) => input.field_types[id] === item.field_type,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
使用固定输入中的 {item.field_type} 字段
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
.research-workspace {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 24px 28px;
|
||||
color: var(--semi-color-text-0);
|
||||
min-width: 0;
|
||||
}
|
||||
.research-page-heading,
|
||||
.research-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.research-page-heading {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.research-page-heading h2 {
|
||||
font-size: 22px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.research-workspace p,
|
||||
.research-hint {
|
||||
font-size: 13px;
|
||||
color: var(--semi-color-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.research-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.research-library {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
max-height: calc(100vh - 180px);
|
||||
overflow: auto;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.research-library h3,
|
||||
.research-card h3 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.research-library > h3 {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.research-library-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.research-library-item.selected,
|
||||
.research-library-item:hover {
|
||||
background: var(--semi-color-fill-0);
|
||||
}
|
||||
.research-library-item span {
|
||||
font-size: 12px;
|
||||
color: var(--semi-color-text-2);
|
||||
}
|
||||
.research-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
.research-card {
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: var(--semi-color-bg-1);
|
||||
min-width: 0;
|
||||
}
|
||||
.research-card label,
|
||||
.research-editor label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
margin: 12px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.research-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.research-toolbar,
|
||||
.research-methods,
|
||||
.research-lineage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.research-toolbar > .semi-input-wrapper {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
.research-lineage {
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-variable {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 0.5fr) repeat(2, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--semi-color-border);
|
||||
}
|
||||
.research-variable > .semi-input-textarea-wrapper {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.research-variable > button {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
.research-code-editor textarea,
|
||||
.research-workspace code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-workspace pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
background: var(--semi-color-fill-0);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.research-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.research-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
.research-table th {
|
||||
color: var(--semi-color-text-2);
|
||||
font-weight: 500;
|
||||
background: var(--semi-color-fill-0);
|
||||
}
|
||||
.research-table th,
|
||||
.research-table td {
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--semi-color-border);
|
||||
}
|
||||
.research-table td {
|
||||
max-width: 350px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-table details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.research-workspace summary {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.research-comparison-grid,
|
||||
.research-operator-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.research-operator-layout {
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
|
||||
}
|
||||
.research-comparison-grid article {
|
||||
padding-top: 12px;
|
||||
}
|
||||
.sidebar-group-heading {
|
||||
color: var(--semi-color-text-2);
|
||||
font-size: 11px;
|
||||
padding: 18px 12px 8px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.research-layout {
|
||||
grid-template-columns: 170px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.research-workspace {
|
||||
padding: 20px;
|
||||
}
|
||||
.research-operator-layout,
|
||||
.research-comparison-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.research-layout {
|
||||
display: block;
|
||||
}
|
||||
.research-library {
|
||||
position: static;
|
||||
max-height: 200px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.research-page-heading {
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.research-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Candidate } from "../backtests/types";
|
||||
export type Variable = {
|
||||
kind:
|
||||
| "field"
|
||||
| "operator"
|
||||
| "integer"
|
||||
| "number"
|
||||
| "group"
|
||||
| "string"
|
||||
| "fragment";
|
||||
values: (string | number)[];
|
||||
field_type?: "MATRIX" | "VECTOR" | "GROUP" | null;
|
||||
};
|
||||
export type Template = {
|
||||
name: string;
|
||||
description: string;
|
||||
expression: string;
|
||||
variables: Record<string, Variable>;
|
||||
category: "template" | "fragment";
|
||||
scope?: InputSnapshot["scope"] | null;
|
||||
};
|
||||
export type Asset = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
version: number;
|
||||
content: Template;
|
||||
created_at?: string;
|
||||
};
|
||||
export type InputSnapshot = {
|
||||
id: string;
|
||||
scope: {
|
||||
instrument_type: string;
|
||||
region: string;
|
||||
universe: string;
|
||||
delay: 0 | 1;
|
||||
};
|
||||
dataset_id: string;
|
||||
field_ids: string[];
|
||||
field_types: Record<string, string>;
|
||||
collection_version: string;
|
||||
created_at: string;
|
||||
};
|
||||
export type ResearchCandidate = Candidate & {
|
||||
validation: {
|
||||
status: string;
|
||||
syntax: string[];
|
||||
types: string[];
|
||||
availability: string[];
|
||||
};
|
||||
bindings: Record<string, unknown>;
|
||||
changes: unknown;
|
||||
};
|
||||
export type Experiment = {
|
||||
id: string;
|
||||
name: string;
|
||||
hypothesis: string;
|
||||
kind: string;
|
||||
inputs: InputSnapshot[];
|
||||
parents: { kind: string; id: string; expression?: string }[];
|
||||
candidates: ResearchCandidate[];
|
||||
evidence: {
|
||||
template?: Asset;
|
||||
combination_count?: string;
|
||||
seed?: number;
|
||||
method?: string;
|
||||
};
|
||||
backtest_run_ids: string[];
|
||||
created_at: string;
|
||||
};
|
||||
export const blankTemplate = (): Template => ({
|
||||
name: "新研究模板",
|
||||
description: "",
|
||||
expression: "rank({field})",
|
||||
variables: { field: { kind: "field", field_type: "MATRIX", values: [] } },
|
||||
category: "template",
|
||||
});
|
||||
export const validationLabel: Record<string, string> = {
|
||||
valid: "本地校验通过",
|
||||
needs_review: "待核实",
|
||||
invalid: "校验失败",
|
||||
};
|
||||
@@ -128,81 +128,6 @@ label:not(.semi-checkbox):not(.semi-radio) {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar {
|
||||
background: #f9f9f9;
|
||||
color: #1f2329;
|
||||
width: 176px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--space-2) var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.brand {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-2);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.brand-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: #1456f0;
|
||||
color: #fff;
|
||||
font:
|
||||
24px Georgia,
|
||||
serif;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #1f2329;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin-bottom: var(--space-1);
|
||||
border-radius: 6px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: #1f23290a;
|
||||
}
|
||||
.nav-item.active {
|
||||
background: #1f23290d;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar-bottom {
|
||||
margin-top: auto;
|
||||
padding: var(--space-3) var(--space-2) 0;
|
||||
}
|
||||
.connection-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
}
|
||||
.connection-line i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #8f959e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.connection-line i.connected {
|
||||
background: #35bd4b;
|
||||
}
|
||||
.main-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -210,38 +135,6 @@ label:not(.semi-checkbox):not(.semi-radio) {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.topbar {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
padding: 0 var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.breadcrumbs {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.account-name {
|
||||
font-size: 13px;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.top-divider {
|
||||
height: 20px;
|
||||
border-left: 1px solid var(--line);
|
||||
margin: 0 var(--space-1);
|
||||
}
|
||||
.page-content {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
@@ -901,44 +794,6 @@ label:not(.semi-checkbox):not(.semi-radio) {
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand {
|
||||
margin: 0 var(--space-2) 0 0;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
.brand > div,
|
||||
.sidebar-bottom {
|
||||
display: none;
|
||||
}
|
||||
.nav-item {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
.topbar {
|
||||
height: 44px;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
.breadcrumbs,
|
||||
.account-name,
|
||||
.top-divider {
|
||||
display: none;
|
||||
}
|
||||
.top-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.page-content {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user