Files
worldquant-alpha-system/frontend/src/App.tsx
T

585 lines
20 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useState } from "react";
import {
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 { IconSetting, IconComment, IconHistory } from "@douyinfe/semi-icons";
import { QuantFlowPage } from "./research/QuantFlowPage";
import { PipelinePage } from "./research/PipelinePage";
import { FeaturesPage } from "./research/FeaturesPage";
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 { MCPKeysPage } from "./pages/MCPKeysPage";
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";
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),
);
const [visitedBacktests, setVisitedBacktests] = useState(
page === "backtests",
);
useEffect(() => {
if (page === "backtests") setVisitedBacktests(true);
}, [page]);
const [visitedResearch, setVisitedResearch] = useState<string[]>([]);
const [researchContext, setResearchContext] = useState<PageContext>({
page: "templates",
});
useEffect(() => {
if (
[
"operators",
"templates",
"variants",
"features",
"pipeline",
"quantflow",
].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>
>({
alphas: 0,
datasets: 0,
backtests: 0,
jobs: 0,
account: 0,
});
const [pollError, setPollError] = useState("");
const [chatOpen, setChatOpen] = useState(false);
const [chatWidth, setChatWidth] = useState(420);
const [viewport, setViewport] = useState(window.innerWidth);
const [alphaContext, setAlphaContext] = useState<PageContext>({
page: "alphas",
});
const [backtestContext, setBacktestContext] = useState<PageContext>({
page: "backtests",
});
const [datasetContext, setDatasetContext] = useState<PageContext>({
page: "datasets",
});
const [aiAction, setAIAction] = useState<UIAction | null>(null);
const chatOffset = viewport >= 1440 && chatOpen ? chatWidth : 0;
const focusBusiness = useCallback(() => {
if (window.innerWidth < 1440) setChatOpen(false);
}, []);
useEffect(() => {
const resize = () => setViewport(window.innerWidth);
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
useEffect(() => {
document.documentElement.style.setProperty(
"--chat-space",
`${chatOffset}px`,
);
document.documentElement.style.setProperty(
"--chat-width",
`${chatWidth}px`,
);
return () => {
document.documentElement.style.setProperty("--chat-space", "0px");
};
}, [chatOffset, chatWidth]);
const refresh = useCallback(async () => {
try {
const [nextAccount, nextJobs] = await Promise.all([
api<Account>("/account"),
api<Job[]>("/sync-jobs"),
]);
setAccount(nextAccount);
setJobs(nextJobs);
setPollError("");
} catch (error) {
setPollError((error as Error).message);
}
}, []);
useEffect(() => {
api("/auth/me")
.then(() => setAuthenticated(true))
.catch(() => setAuthenticated(false));
const expired = () => {
setSettingsOpen(false);
setAuthenticated(false);
setAccount(null);
setJobs([]);
setChatOpen(false);
setShowJobs(false);
};
window.addEventListener("session-expired", expired);
const hash = () => setPage(pageFromHash(location.hash));
window.addEventListener("hashchange", hash);
return () => {
window.removeEventListener("session-expired", expired);
window.removeEventListener("hashchange", hash);
};
}, []);
useEffect(() => {
if (!authenticated) return;
void refresh();
const timer = window.setInterval(() => void refresh(), 3000);
return () => clearInterval(timer);
}, [authenticated, refresh]);
useEffect(() => {
document.body.setAttribute("theme-mode", account?.theme ?? "light");
}, [account?.theme]);
const completedVersion = jobs
.filter(
(j) => j.status === "completed" || j.status === "completed_with_errors",
)
.map((j) => `${j.id}:${j.updated_at}`)
.join("|");
const pending = jobs.filter((j) =>
["queued", "running", "waiting_auth", "waiting_connection"].includes(
j.status,
),
).length;
const actionDone = () => {
void refresh();
setRefreshKey((k) => k + 1);
};
const aiChanged = (resources: Resource[]) => {
if (resources.includes("jobs") || resources.includes("account"))
void refresh();
setResourceVersions((old) => {
const next = { ...old };
for (const resource of resources)
if (Object.hasOwn(next, resource)) next[resource] += 1;
return next;
});
};
const taskCreated = () => {
actionDone();
setShowJobs(true);
};
const changePage = (next: WorkspacePage) => {
location.hash = next;
setPage(next);
};
const handleAction = (action: UIAction) => {
const destination = actionDestination(action);
if (!destination) return;
if (destination.chat === "open") setChatOpen(true);
else if (destination.chat === "close") setChatOpen(false);
else focusBusiness();
if (destination.page) changePage(destination.page);
setAIAction(action);
};
const logout = async () => {
try {
await post("/auth/logout");
setSettingsOpen(false);
setAuthenticated(false);
setChatOpen(false);
setShowJobs(false);
setAccount(null);
setJobs([]);
setAIAction(null);
setAlphaContext({ page: "alphas" });
setDatasetContext({ page: "datasets" });
setBacktestContext({ page: "backtests" });
} catch (e) {
Toast.error((e as Error).message);
}
};
return (
<LocaleProvider locale={zhCN}>
{authenticated === null ? (
<div className="screen-center">
<Spin size="large" />
</div>
) : !authenticated ? (
<Login
onLogin={() => {
setAuthenticated(true);
void refresh();
}}
/>
) : (
<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="工作空间工具"
>
<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}
>
<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);
}}
/>
</Badge>
</Tooltip>
</div>
<div
className="main-shell"
inert={
(chatOpen && viewport < 1440) || catalogModal || settingsOpen
}
aria-hidden={catalogModal || undefined}
>
<main
className={`page-content ${page !== "account" ? "bounded-page" : "account-page"}`}
>
{page === "mcp-keys" && <MCPKeysPage />}
{pollError && (
<Banner
type="warning"
description={`后台状态暂时不可用:${pollError}`}
/>
)}
<div hidden={page !== "account"}>
<AccountPage
account={account}
onChange={actionDone}
onTask={taskCreated}
/>
</div>
<div className="alpha-page-view" hidden={page !== "datasets"}>
<DatasetPage
onContext={setDatasetContext}
action={aiAction}
account={account}
jobs={jobs}
active={page === "datasets"}
version={`${refreshKey}:${resourceVersions.datasets}:${completedVersion}`}
suspended={showJobs || chatOpen}
onTask={taskCreated}
onModal={setCatalogModal}
onChat={() => setChatOpen(true)}
/>
</div>
<div className="alpha-page-view" hidden={page !== "alphas"}>
<AlphaPage
onAction={handleAction}
taskPanelOpen={showJobs}
account={account}
version={`${refreshKey}:${resourceVersions.alphas}:${completedVersion}`}
onTask={taskCreated}
onAccount={() => changePage("account")}
active={page === "alphas"}
overlaySuspended={
page !== "alphas" || (viewport < 1440 && chatOpen)
}
chatOffset={chatOffset}
onContext={setAlphaContext}
action={
aiAction?.type === "open_alpha" ||
aiAction?.type === "apply_filters"
? aiAction
: null
}
onOverlay={focusBusiness}
/>
</div>
<div className="backtest-page-view" hidden={page !== "backtests"}>
{visitedBacktests && (
<BacktestPage
version={resourceVersions.backtests}
active={page === "backtests"}
suspended={showJobs || (viewport < 1440 && chatOpen)}
chatOffset={chatOffset}
timezone={account?.timezone}
action={aiAction}
onContext={setBacktestContext}
onAction={handleAction}
/>
)}
</div>
{visitedResearch.includes("quantflow") && (
<div className="alpha-page-view" hidden={page !== "quantflow"}>
<QuantFlowPage
active={page === "quantflow"}
onAction={handleAction}
onContext={setResearchContext}
/>
</div>
)}
{visitedResearch.includes("pipeline") && (
<div className="alpha-page-view" hidden={page !== "pipeline"}>
<PipelinePage
active={page === "pipeline"}
onAction={handleAction}
onContext={setResearchContext}
/>
</div>
)}
{visitedResearch.includes("features") && (
<div className="alpha-page-view" hidden={page !== "features"}>
<FeaturesPage
action={aiAction}
active={page === "features"}
onAction={handleAction}
onContext={setResearchContext}
/>
</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}
jobs={jobs}
timezone={account?.timezone}
onClose={() => setShowJobs(false)}
onChange={actionDone}
onAccount={() => {
setShowJobs(false);
changePage("account");
}}
/>
{chatOpen && viewport < 1440 && (
<div
className="ai-mask"
aria-hidden="true"
onClick={() => setChatOpen(false)}
/>
)}
<div inert={catalogModal && !chatOpen}>
<ChatPanel
action={aiAction}
jobs={jobs}
open={chatOpen}
width={chatWidth}
onWidth={setChatWidth}
onClose={() => setChatOpen(false)}
context={
{
quantflow:
researchContext.page === "quantflow"
? researchContext
: { page: "quantflow" as const },
pipeline:
researchContext.page === "pipeline"
? researchContext
: { page: "pipeline" as const },
features:
researchContext.page === "features"
? researchContext
: { page: "features" as const },
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,
account: { page: "account" as const },
"mcp-keys": { page: "account" as const },
}[page]
}
timezone={account?.timezone}
onSettings={openSettings}
onChanged={aiChanged}
onAction={handleAction}
/>
</div>
</div>
)}
</LocaleProvider>
);
}
function Login({ onLogin }: { onLogin: () => void }) {
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await post("/auth/login", { username, password });
setPassword("");
onLogin();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<div className="login-page">
<section className="login-form-shell">
<form className="login-form" onSubmit={submit}>
<div className="login-brand">Alpha 研究</div>
<h2>登录研究工作空间</h2>
<p>使用部署时设置的系统账户登录。</p>
{error && <Banner type="danger" description={error} />}
<label>
用户名
<Input
aria-label="用户名"
value={username}
onChange={setUsername}
autoComplete="username"
size="large"
/>
</label>
<label>
密码
<Input
aria-label="密码"
mode="password"
value={password}
onChange={setPassword}
autoComplete="current-password"
size="large"
/>
</label>
<Button
htmlType="submit"
theme="solid"
size="large"
block
loading={busy}
>
进入工作空间
</Button>
<div className="login-note">WorldQuant 账户在登录后单独连接。</div>
</form>
</section>
</div>
);
}