598 lines
20 KiB
TypeScript
598 lines
20 KiB
TypeScript
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,
|
|
IconAIFilledLevel3,
|
|
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 { HomePage } from "./pages/HomePage";
|
|
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 || (chatOpen && viewport < 1440)}
|
|
inert={
|
|
(chatOpen && viewport < 1440) || catalogModal || settingsOpen
|
|
}
|
|
role="toolbar"
|
|
aria-label="工作空间工具"
|
|
>
|
|
<Tooltip content="配置" visible={settingsOpen ? false : undefined}>
|
|
<Button
|
|
aria-label="配置"
|
|
icon={<IconSetting aria-hidden="true" />}
|
|
type="tertiary"
|
|
theme="borderless"
|
|
onClick={openSettings}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip
|
|
content="AI 研究助手"
|
|
visible={chatOpen ? false : undefined}
|
|
>
|
|
<Button
|
|
aria-label="切换研究助手"
|
|
aria-expanded={chatOpen}
|
|
aria-controls="research-assistant"
|
|
icon={<IconAIFilledLevel3 aria-hidden="true" />}
|
|
colorful
|
|
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 aria-hidden="true" />}
|
|
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 === "home" && (
|
|
<HomePage
|
|
account={account}
|
|
onAccount={() => changePage("account")}
|
|
/>
|
|
)}
|
|
{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={
|
|
{
|
|
home: { page: "home" as const },
|
|
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>
|
|
);
|
|
}
|