refactor: unify AI capabilities and workspace integration
This commit is contained in:
+42
-46
@@ -18,20 +18,16 @@ import { AlphaPage } from "./pages/AlphaPage";
|
||||
import { JobPanel } from "./components/JobPanel";
|
||||
import { BacktestPage } from "./backtests/BacktestPage";
|
||||
import { ChatPanel } from "./ai/ChatPanel";
|
||||
import type { PageContext, UIAction } from "./ai/types";
|
||||
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 [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [page, setPage] = useState(
|
||||
location.hash === "#backtests"
|
||||
? "backtests"
|
||||
: location.hash === "#datasets"
|
||||
? "datasets"
|
||||
: location.hash === "#account"
|
||||
? "account"
|
||||
: "alphas",
|
||||
const [page, setPage] = useState<WorkspacePage>(() =>
|
||||
pageFromHash(location.hash),
|
||||
);
|
||||
const [visitedBacktests, setVisitedBacktests] = useState(
|
||||
page === "backtests",
|
||||
@@ -42,6 +38,15 @@ export default function App() {
|
||||
const [catalogModal, setCatalogModal] = useState(false);
|
||||
const [showJobs, setShowJobs] = useState(false);
|
||||
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);
|
||||
@@ -104,16 +109,7 @@ export default function App() {
|
||||
setShowJobs(false);
|
||||
};
|
||||
window.addEventListener("session-expired", expired);
|
||||
const hash = () =>
|
||||
setPage(
|
||||
location.hash === "#backtests"
|
||||
? "backtests"
|
||||
: location.hash === "#datasets"
|
||||
? "datasets"
|
||||
: location.hash === "#account"
|
||||
? "account"
|
||||
: "alphas",
|
||||
);
|
||||
const hash = () => setPage(pageFromHash(location.hash));
|
||||
window.addEventListener("hashchange", hash);
|
||||
return () => {
|
||||
window.removeEventListener("session-expired", expired);
|
||||
@@ -145,31 +141,31 @@ export default function App() {
|
||||
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: string) => {
|
||||
const changePage = (next: WorkspacePage) => {
|
||||
location.hash = next;
|
||||
setPage(next);
|
||||
};
|
||||
const handleAction = (action: UIAction) => {
|
||||
if (action.type === "open_conversation") {
|
||||
setChatOpen(true);
|
||||
} else {
|
||||
focusBusiness();
|
||||
if (action.type === "open_research_input") {
|
||||
setChatOpen(false);
|
||||
changePage("datasets");
|
||||
} else {
|
||||
changePage(
|
||||
action.type === "open_backtest" ||
|
||||
action.type === "open_backtest_preview"
|
||||
? "backtests"
|
||||
: "alphas",
|
||||
);
|
||||
}
|
||||
}
|
||||
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 () => {
|
||||
@@ -330,7 +326,7 @@ export default function App() {
|
||||
account={account}
|
||||
jobs={jobs}
|
||||
active={page === "datasets"}
|
||||
version={`${refreshKey}:${completedVersion}`}
|
||||
version={`${refreshKey}:${resourceVersions.datasets}:${completedVersion}`}
|
||||
suspended={showJobs || chatOpen}
|
||||
onTask={taskCreated}
|
||||
onModal={setCatalogModal}
|
||||
@@ -342,7 +338,7 @@ export default function App() {
|
||||
onAction={handleAction}
|
||||
taskPanelOpen={showJobs}
|
||||
account={account}
|
||||
version={`${refreshKey}:${completedVersion}`}
|
||||
version={`${refreshKey}:${resourceVersions.alphas}:${completedVersion}`}
|
||||
onTask={taskCreated}
|
||||
onAccount={() => changePage("account")}
|
||||
active={page === "alphas"}
|
||||
@@ -363,6 +359,7 @@ export default function App() {
|
||||
<div className="backtest-page-view" hidden={page !== "backtests"}>
|
||||
{visitedBacktests && (
|
||||
<BacktestPage
|
||||
version={resourceVersions.backtests}
|
||||
active={page === "backtests"}
|
||||
suspended={showJobs || (viewport < 1440 && chatOpen)}
|
||||
chatOffset={chatOffset}
|
||||
@@ -414,13 +411,12 @@ export default function App() {
|
||||
onWidth={setChatWidth}
|
||||
onClose={() => setChatOpen(false)}
|
||||
context={
|
||||
page === "alphas"
|
||||
? alphaContext
|
||||
: page === "backtests"
|
||||
? backtestContext
|
||||
: page === "datasets"
|
||||
? datasetContext
|
||||
: { page: "account" }
|
||||
{
|
||||
alphas: alphaContext,
|
||||
datasets: datasetContext,
|
||||
backtests: backtestContext,
|
||||
account: { page: "account" as const },
|
||||
}[page]
|
||||
}
|
||||
timezone={account?.timezone}
|
||||
onSettings={() => {
|
||||
@@ -432,7 +428,7 @@ export default function App() {
|
||||
?.scrollIntoView({ block: "start" }),
|
||||
);
|
||||
}}
|
||||
onChanged={actionDone}
|
||||
onChanged={aiChanged}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Toast } from "@douyinfe/semi-ui-19";
|
||||
import { api, formatNumber, formatTime, stateLabels } from "../api";
|
||||
import { PnlChart } from "../components/PnlChart";
|
||||
import type { Alpha, Pnl, Research } from "../types";
|
||||
import type { ToolCardProps } from "./types";
|
||||
|
||||
export function AlphaToolCard({ call, onAction, timezone }: ToolCardProps) {
|
||||
const result = call.result ?? {};
|
||||
const items = Array.isArray(result.items) ? result.items : [];
|
||||
const isSearch = call.name === "search_alphas";
|
||||
const alphas = (
|
||||
isSearch ? items : call.name === "get_alpha" && result.id ? [result] : []
|
||||
) as Alpha[];
|
||||
const openAlpha = (id: string) =>
|
||||
onAction({ type: "open_alpha", alpha_id: id, nonce: Date.now() });
|
||||
return (
|
||||
<>
|
||||
{call.preview.targets?.map((target) => (
|
||||
<details
|
||||
key={target.alpha_id}
|
||||
open={call.preview.targets!.length === 1}
|
||||
>
|
||||
<summary>{target.alpha_id} 的修改</summary>
|
||||
{(["note", "tags", "favorite", "state"] as const)
|
||||
.filter(
|
||||
(key) =>
|
||||
JSON.stringify(target.before[key]) !==
|
||||
JSON.stringify(target.after[key]),
|
||||
)
|
||||
.map((key) => (
|
||||
<div className="ai-diff" key={key}>
|
||||
<strong>
|
||||
{
|
||||
{
|
||||
note: "备注",
|
||||
tags: "标签",
|
||||
favorite: "收藏",
|
||||
state: "研究状态",
|
||||
}[key]
|
||||
}
|
||||
</strong>
|
||||
<del>
|
||||
<span>修改前</span>
|
||||
{researchValue(key, target.before[key])}
|
||||
</del>
|
||||
<ins>
|
||||
<span>修改后</span>
|
||||
{researchValue(key, target.after[key])}
|
||||
</ins>
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
))}
|
||||
{alphas.length > 0 && (
|
||||
<div className="ai-alpha-results">
|
||||
{alphas.map((alpha) => (
|
||||
<div key={alpha.id}>
|
||||
<button className="text-link" onClick={() => openAlpha(alpha.id)}>
|
||||
{alpha.name || alpha.id}
|
||||
</button>
|
||||
<small>
|
||||
{alpha.id} · {alpha.region ?? "地区未提供"}
|
||||
</small>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sharpe</th>
|
||||
<th>Fitness</th>
|
||||
<th>Turnover</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{formatNumber(alpha.sharpe)}</td>
|
||||
<td>{formatNumber(alpha.fitness)}</td>
|
||||
<td>
|
||||
{alpha.turnover == null
|
||||
? "未提供"
|
||||
: `${formatNumber(alpha.turnover * 100)}%`}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<small>本地快照 · {formatTime(alpha.synced_at, timezone)}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isSearch && (
|
||||
<p>
|
||||
共 {String(result.total ?? 0)} 条,当前返回 {items.length} 条。
|
||||
{!!result.filters && (
|
||||
<button
|
||||
className="text-link"
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "apply_filters",
|
||||
filters: result.filters as Record<string, unknown>,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
应用到列表
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{call.name === "get_alpha_facets" && call.status === "completed" && (
|
||||
<p>
|
||||
已同步 {String(result.total ?? 0)} 条 Alpha · 收藏{" "}
|
||||
{String(result.favorites ?? 0)} 条<br />
|
||||
地区:
|
||||
{Array.isArray(result.region) ? result.region.join("、") : "未提供"}
|
||||
</p>
|
||||
)}
|
||||
{call.name === "get_alpha_pnl" && call.status === "completed" && (
|
||||
<ChatPnl result={result} timezone={timezone} />
|
||||
)}
|
||||
{result.ok === true && (
|
||||
<p>
|
||||
操作已保存。
|
||||
{typeof result.alpha_id === "string" && (
|
||||
<button
|
||||
className="text-link"
|
||||
onClick={() => openAlpha(result.alpha_id as string)}
|
||||
>
|
||||
打开 Alpha
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{typeof result.updated === "number" && (
|
||||
<p>已更新 {result.updated} 条研究记录。</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
function researchValue(key: keyof Research, value: unknown) {
|
||||
if (key === "favorite") return value ? "已收藏" : "未收藏";
|
||||
if (key === "state")
|
||||
return (
|
||||
stateLabels[String(value) as keyof typeof stateLabels] ?? String(value)
|
||||
);
|
||||
if (Array.isArray(value)) return value.join("、") || "无";
|
||||
return String(value || "空");
|
||||
}
|
||||
|
||||
function ChatPnl({
|
||||
result,
|
||||
timezone,
|
||||
}: {
|
||||
result: Record<string, unknown>;
|
||||
timezone?: string;
|
||||
}) {
|
||||
const [pnl, setPnl] = useState<Pnl | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (result.cached && typeof result.alpha_id === "string")
|
||||
api<Pnl>(`/alphas/${encodeURIComponent(result.alpha_id)}/pnl`)
|
||||
.then((value) => {
|
||||
if (active) setPnl(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) Toast.error(error.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [result.alpha_id, result.fetched_at, result.cached]);
|
||||
return result.cached ? (
|
||||
<div>
|
||||
{pnl && <PnlChart points={pnl.points} />}
|
||||
<small>
|
||||
{String(result.count)} 条记录 · 缓存于{" "}
|
||||
{formatTime(result.fetched_at as string, timezone)}
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
<p>尚无 PnL 缓存,可以请求创建 PnL 刷新任务。</p>
|
||||
);
|
||||
}
|
||||
+22
-351
@@ -15,23 +15,13 @@ import {
|
||||
Spin,
|
||||
Tag,
|
||||
TextArea,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
api,
|
||||
formatNumber,
|
||||
formatTime,
|
||||
jobLabels,
|
||||
jobStateLabels,
|
||||
post,
|
||||
stateLabels,
|
||||
} from "../api";
|
||||
import { BacktestToolCard } from "../backtests/BacktestToolCard";
|
||||
import { CatalogToolCard } from "../research/CatalogToolCard";
|
||||
import { PnlChart } from "../components/PnlChart";
|
||||
import type { Alpha, Job, Pnl, Research } from "../types";
|
||||
import { api, post } from "../api";
|
||||
import type { Job } from "../types";
|
||||
import { BusinessCard } from "./ToolCard";
|
||||
import { contextLabel } from "./workspace";
|
||||
import { chatTransport } from "./transport";
|
||||
import { runLabels, toolLabels } from "./types";
|
||||
import { runLabels } from "./types";
|
||||
import type {
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
@@ -39,7 +29,7 @@ import type {
|
||||
ModelSettings,
|
||||
PageContext,
|
||||
RunSnapshot,
|
||||
ToolCard,
|
||||
Resource,
|
||||
UIAction,
|
||||
} from "./types";
|
||||
|
||||
@@ -66,7 +56,7 @@ export function ChatPanel({
|
||||
context: PageContext;
|
||||
onClose: () => void;
|
||||
onAction: (action: UIAction) => void;
|
||||
onChanged: () => void;
|
||||
onChanged: (resources: Resource[]) => void;
|
||||
onSettings: () => void;
|
||||
width: number;
|
||||
onWidth: (width: number) => void;
|
||||
@@ -85,34 +75,28 @@ export function ChatPanel({
|
||||
const input = useRef<HTMLDivElement>(null);
|
||||
const bottom = useRef<HTMLDivElement>(null);
|
||||
const previousFocus = useRef<HTMLElement | null>(null);
|
||||
const seenWrites = useRef(new Set<string>());
|
||||
const seenChanges = useRef(new Set<string>());
|
||||
const current = useRef({ conversationId, context });
|
||||
current.current = { conversationId, context };
|
||||
const refreshRef = useRef<() => Promise<void>>(async () => {});
|
||||
const changedRef = useRef(onChanged);
|
||||
changedRef.current = onChanged;
|
||||
const observeWrites = useCallback((items: RunSnapshot[]) => {
|
||||
let changed = false;
|
||||
const observeChanges = useCallback((items: RunSnapshot[]) => {
|
||||
const changed = new Set<Resource>();
|
||||
for (const run of items)
|
||||
for (const call of run.tools ?? []) {
|
||||
if (
|
||||
call.status === "completed" &&
|
||||
[
|
||||
"update_research",
|
||||
"bulk_update_research",
|
||||
"create_sync_job",
|
||||
"cancel_job",
|
||||
"retry_job",
|
||||
"start_backtest",
|
||||
"control_backtest",
|
||||
].includes(call.name) &&
|
||||
!seenWrites.current.has(call.id)
|
||||
call.presentation?.refresh.length &&
|
||||
!seenChanges.current.has(call.id)
|
||||
) {
|
||||
seenWrites.current.add(call.id);
|
||||
changed = true;
|
||||
seenChanges.current.add(call.id);
|
||||
call.presentation.refresh.forEach((resource) =>
|
||||
changed.add(resource),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (changed) changedRef.current();
|
||||
if (changed.size) changedRef.current([...changed]);
|
||||
}, []);
|
||||
const transport = useMemo(() => chatTransport(() => current.current), []);
|
||||
const chat = useChat<ChatMessage>({
|
||||
@@ -123,7 +107,7 @@ export function ChatPanel({
|
||||
if (part.type === "data-run") {
|
||||
const next = part.data;
|
||||
if (next.conversation_id !== current.current.conversationId) return;
|
||||
observeWrites([next]);
|
||||
observeChanges([next]);
|
||||
setRuns((items) => {
|
||||
const old = items.find((run) => run.id === next.id);
|
||||
const merged = {
|
||||
@@ -151,7 +135,7 @@ export function ChatPanel({
|
||||
if (current.current.conversationId !== id) return;
|
||||
chat.setMessages(detail.messages);
|
||||
setRuns(detail.runs);
|
||||
observeWrites(detail.runs);
|
||||
observeChanges(detail.runs);
|
||||
setConversations((items) =>
|
||||
items.map((item) =>
|
||||
item.id === id ? { id, title: detail.title } : item,
|
||||
@@ -160,7 +144,7 @@ export function ChatPanel({
|
||||
} catch (e) {
|
||||
setFailure((e as Error).message);
|
||||
}
|
||||
}, [chat.setMessages, observeWrites]);
|
||||
}, [chat.setMessages, observeChanges]);
|
||||
refreshRef.current = refreshConversation;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -301,7 +285,7 @@ export function ChatPanel({
|
||||
await post(`/ai/runs/${activeRun.id}/cancel`);
|
||||
await chat.stop();
|
||||
await refreshConversation();
|
||||
changedRef.current();
|
||||
// Completed tools are reconciled by refreshConversation; cancelling has no new business effect.
|
||||
} catch (e) {
|
||||
setFailure((e as Error).message);
|
||||
}
|
||||
@@ -498,15 +482,7 @@ export function ChatPanel({
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
<footer className="ai-composer" ref={input}>
|
||||
<div className="ai-context">
|
||||
{context.page === "backtests"
|
||||
? "上下文:回测研究"
|
||||
: context.page === "datasets"
|
||||
? `上下文:${context.dataset_id ?? "数据目录"}${context.catalog_scope ? ` · ${context.catalog_scope.region}/${context.catalog_scope.universe}/D${context.catalog_scope.delay}` : ""}${context.template_input_id ? " · 固定研究输入" : context.unsaved_field_selection ? " · 请先保存字段选择" : ""}(不发送未保存备注)`
|
||||
: context.page === "account"
|
||||
? "上下文:个人信息页"
|
||||
: `上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`}
|
||||
</div>
|
||||
<div className="ai-context">{contextLabel(context)}</div>
|
||||
<TextArea
|
||||
aria-label="发送给研究助手"
|
||||
placeholder={
|
||||
@@ -556,308 +532,3 @@ export function ChatPanel({
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function BusinessCard({
|
||||
call,
|
||||
onDecision,
|
||||
disabled,
|
||||
onAction,
|
||||
timezone,
|
||||
jobs,
|
||||
}: {
|
||||
call: ToolCard;
|
||||
onDecision: (id: string, approved: boolean) => Promise<void>;
|
||||
disabled: boolean;
|
||||
onAction: (action: UIAction) => void;
|
||||
timezone?: string;
|
||||
jobs: Job[];
|
||||
}) {
|
||||
const result = call.result ?? {};
|
||||
const jobId = result.job_id ?? result.id;
|
||||
const job = jobs.find((item) => item.id === jobId);
|
||||
const jobOperation = [
|
||||
"get_job_status",
|
||||
"create_sync_job",
|
||||
"retry_job",
|
||||
"cancel_job",
|
||||
].includes(call.name);
|
||||
const pending = call.status === "pending";
|
||||
const items = Array.isArray(result.items) ? result.items : [];
|
||||
const isSearch = call.name === "search_alphas";
|
||||
const alphas = (
|
||||
isSearch ? items : call.name === "get_alpha" && result.id ? [result] : []
|
||||
) as Alpha[];
|
||||
const labels: Record<string, string> = {
|
||||
pending: "待确认",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
denied: "已拒绝",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
const openAlpha = (id: string) =>
|
||||
onAction({ type: "open_alpha", alpha_id: id, nonce: Date.now() });
|
||||
return (
|
||||
<section className="ai-tool-card" data-status={call.status}>
|
||||
<div className="ai-card-title">
|
||||
<strong>{toolLabels[call.name] ?? "业务操作"}</strong>
|
||||
<Tag
|
||||
color={pending ? "orange" : call.status === "failed" ? "red" : "grey"}
|
||||
>
|
||||
{labels[call.status] ?? call.status}
|
||||
</Tag>
|
||||
</div>
|
||||
{call.name.includes("backtest") && (
|
||||
<BacktestToolCard call={call} onAction={onAction} />
|
||||
)}
|
||||
<CatalogToolCard call={call} onAction={onAction} />
|
||||
{call.preview.targets?.map((target) => (
|
||||
<details
|
||||
key={target.alpha_id}
|
||||
open={call.preview.targets!.length === 1}
|
||||
>
|
||||
<summary>{target.alpha_id} 的修改</summary>
|
||||
{(["note", "tags", "favorite", "state"] as const)
|
||||
.filter(
|
||||
(key) =>
|
||||
JSON.stringify(target.before[key]) !==
|
||||
JSON.stringify(target.after[key]),
|
||||
)
|
||||
.map((key) => (
|
||||
<div className="ai-diff" key={key}>
|
||||
<strong>
|
||||
{
|
||||
{
|
||||
note: "备注",
|
||||
tags: "标签",
|
||||
favorite: "收藏",
|
||||
state: "研究状态",
|
||||
}[key]
|
||||
}
|
||||
</strong>
|
||||
<del>
|
||||
<span>修改前</span>
|
||||
{researchValue(key, target.before[key])}
|
||||
</del>
|
||||
<ins>
|
||||
<span>修改后</span>
|
||||
{researchValue(key, target.after[key])}
|
||||
</ins>
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
))}
|
||||
{call.preview.operation && (
|
||||
<p>
|
||||
{jobLabels[String(call.preview.operation.kind)] ?? "创建任务"} ·{" "}
|
||||
{Array.isArray(call.preview.operation.alpha_ids) &&
|
||||
call.preview.operation.alpha_ids.length
|
||||
? call.preview.operation.alpha_ids.join("、")
|
||||
: call.preview.operation.submission === "UNSUBMITTED"
|
||||
? "待提交 Alpha"
|
||||
: call.preview.operation.submission === "SUBMITTED"
|
||||
? "已提交 Alpha"
|
||||
: "全部 Alpha"}
|
||||
{call.preview.operation.kind === "daily_sync" && (
|
||||
<>
|
||||
{" · "}
|
||||
{call.preview.operation.submission === "UNSUBMITTED"
|
||||
? "创建日期"
|
||||
: "提交日期"}{" "}
|
||||
{String(call.preview.operation.date_from)} 至{" "}
|
||||
{String(call.preview.operation.date_to)}(UTC)
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{call.preview.job && (
|
||||
<p>
|
||||
{jobLabels[String(call.preview.job.kind)]} ·{" "}
|
||||
{String(call.preview.job.id)} ·{" "}
|
||||
{jobStateLabels[String(call.preview.job.status)]}
|
||||
</p>
|
||||
)}
|
||||
{pending && (
|
||||
<div className="ai-approval">
|
||||
<p className="muted">
|
||||
{call.preview.targets?.length
|
||||
? `将修改 ${call.preview.targets.length} 条研究记录。`
|
||||
: "将执行以上任务操作。"}
|
||||
确认后执行。
|
||||
</p>
|
||||
<div className="inline-actions">
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={disabled}
|
||||
onClick={() => void onDecision(call.id, true)}
|
||||
>
|
||||
确认执行
|
||||
</Button>
|
||||
<Button
|
||||
type="tertiary"
|
||||
disabled={disabled}
|
||||
onClick={() => void onDecision(call.id, false)}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{typeof result.error === "string" && (
|
||||
<p className="error-text">{result.error}</p>
|
||||
)}
|
||||
{result.denied === true && <p>已拒绝,未执行此操作。</p>}
|
||||
{alphas.length > 0 && (
|
||||
<div className="ai-alpha-results">
|
||||
{alphas.map((alpha) => (
|
||||
<div key={alpha.id}>
|
||||
<button className="text-link" onClick={() => openAlpha(alpha.id)}>
|
||||
{alpha.name || alpha.id}
|
||||
</button>
|
||||
<small>
|
||||
{alpha.id} · {alpha.region ?? "地区未提供"}
|
||||
</small>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sharpe</th>
|
||||
<th>Fitness</th>
|
||||
<th>Turnover</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{formatNumber(alpha.sharpe)}</td>
|
||||
<td>{formatNumber(alpha.fitness)}</td>
|
||||
<td>
|
||||
{alpha.turnover == null
|
||||
? "未提供"
|
||||
: `${formatNumber(alpha.turnover * 100)}%`}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<small>本地快照 · {formatTime(alpha.synced_at, timezone)}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isSearch && (
|
||||
<p>
|
||||
共 {String(result.total ?? 0)} 条,当前返回 {items.length} 条。
|
||||
{!!result.filters && (
|
||||
<button
|
||||
className="text-link"
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "apply_filters",
|
||||
filters: result.filters as Record<string, unknown>,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
应用到列表
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{call.name === "get_alpha_facets" && call.status === "completed" && (
|
||||
<p>
|
||||
已同步 {String(result.total ?? 0)} 条 Alpha · 收藏{" "}
|
||||
{String(result.favorites ?? 0)} 条<br />
|
||||
地区:
|
||||
{Array.isArray(result.region) ? result.region.join("、") : "未提供"}
|
||||
</p>
|
||||
)}
|
||||
{call.name === "get_alpha_pnl" && call.status === "completed" && (
|
||||
<ChatPnl result={result} timezone={timezone} />
|
||||
)}
|
||||
{call.name === "list_jobs" &&
|
||||
items.map((item) => {
|
||||
const job = item as Record<string, unknown>;
|
||||
return (
|
||||
<p key={String(job.id)}>
|
||||
{jobLabels[String(job.kind)]} ·{" "}
|
||||
{jobStateLabels[String(job.status)]}
|
||||
<small>{String(job.id)}</small>
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
{jobOperation && !!jobId && (
|
||||
<p>
|
||||
{jobLabels[job?.kind ?? String(result.kind)] ?? "任务"} ·{" "}
|
||||
{jobStateLabels[job?.status ?? String(result.status)] ??
|
||||
String(result.status ?? "已处理")}
|
||||
{job && (
|
||||
<span>
|
||||
{" "}
|
||||
· {job.processed}/{job.total ?? "未知"},失败 {job.failed}
|
||||
{job.error ? ` · ${job.error}` : ""}
|
||||
</span>
|
||||
)}
|
||||
<small>{String(jobId)}</small>
|
||||
</p>
|
||||
)}
|
||||
{result.ok === true && (
|
||||
<p>
|
||||
操作已保存。
|
||||
{typeof result.alpha_id === "string" && (
|
||||
<button
|
||||
className="text-link"
|
||||
onClick={() => openAlpha(result.alpha_id as string)}
|
||||
>
|
||||
打开 Alpha
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{typeof result.updated === "number" && (
|
||||
<p>已更新 {result.updated} 条研究记录。</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function researchValue(key: keyof Research, value: unknown) {
|
||||
if (key === "favorite") return value ? "已收藏" : "未收藏";
|
||||
if (key === "state")
|
||||
return (
|
||||
stateLabels[String(value) as keyof typeof stateLabels] ?? String(value)
|
||||
);
|
||||
if (Array.isArray(value)) return value.join("、") || "无";
|
||||
return String(value || "空");
|
||||
}
|
||||
|
||||
function ChatPnl({
|
||||
result,
|
||||
timezone,
|
||||
}: {
|
||||
result: Record<string, unknown>;
|
||||
timezone?: string;
|
||||
}) {
|
||||
const [pnl, setPnl] = useState<Pnl | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (result.cached && typeof result.alpha_id === "string")
|
||||
api<Pnl>(`/alphas/${encodeURIComponent(result.alpha_id)}/pnl`)
|
||||
.then((value) => {
|
||||
if (active) setPnl(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) Toast.error(error.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [result.alpha_id, result.fetched_at, result.cached]);
|
||||
return result.cached ? (
|
||||
<div>
|
||||
{pnl && <PnlChart points={pnl.points} />}
|
||||
<small>
|
||||
{String(result.count)} 条记录 · 缓存于{" "}
|
||||
{formatTime(result.fetched_at as string, timezone)}
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
<p>尚无 PnL 缓存,可以请求创建 PnL 刷新任务。</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { jobLabels, jobStateLabels } from "../api";
|
||||
import type { ToolCardProps } from "./types";
|
||||
|
||||
export function JobToolCard({ call, jobs }: ToolCardProps) {
|
||||
const result = call.result ?? {};
|
||||
const items = Array.isArray(result.items) ? result.items : [];
|
||||
const jobId = result.job_id ?? result.id;
|
||||
const job = jobs.find((item) => item.id === jobId);
|
||||
const jobOperation = call.name !== "list_jobs";
|
||||
return (
|
||||
<>
|
||||
{call.preview.operation && (
|
||||
<p>
|
||||
{jobLabels[String(call.preview.operation.kind)] ?? "创建任务"} ·{" "}
|
||||
{Array.isArray(call.preview.operation.alpha_ids) &&
|
||||
call.preview.operation.alpha_ids.length
|
||||
? call.preview.operation.alpha_ids.join("、")
|
||||
: call.preview.operation.submission === "UNSUBMITTED"
|
||||
? "待提交 Alpha"
|
||||
: call.preview.operation.submission === "SUBMITTED"
|
||||
? "已提交 Alpha"
|
||||
: "全部 Alpha"}
|
||||
{call.preview.operation.kind === "daily_sync" && (
|
||||
<>
|
||||
{" · "}
|
||||
{call.preview.operation.submission === "UNSUBMITTED"
|
||||
? "创建日期"
|
||||
: "提交日期"}{" "}
|
||||
{String(call.preview.operation.date_from)} 至{" "}
|
||||
{String(call.preview.operation.date_to)}(UTC)
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{call.preview.job && (
|
||||
<p>
|
||||
{jobLabels[String(call.preview.job.kind)]} ·{" "}
|
||||
{String(call.preview.job.id)} ·{" "}
|
||||
{jobStateLabels[String(call.preview.job.status)]}
|
||||
</p>
|
||||
)}
|
||||
{call.name === "list_jobs" &&
|
||||
items.map((item) => {
|
||||
const job = item as Record<string, unknown>;
|
||||
return (
|
||||
<p key={String(job.id)}>
|
||||
{jobLabels[String(job.kind)]} ·{" "}
|
||||
{jobStateLabels[String(job.status)]}
|
||||
<small>{String(job.id)}</small>
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
{jobOperation && !!jobId && (
|
||||
<p>
|
||||
{jobLabels[job?.kind ?? String(result.kind)] ?? "任务"} ·{" "}
|
||||
{jobStateLabels[job?.status ?? String(result.status)] ??
|
||||
String(result.status ?? "已处理")}
|
||||
{job && (
|
||||
<span>
|
||||
{" "}
|
||||
· {job.processed}/{job.total ?? "未知"},失败 {job.failed}
|
||||
{job.error ? ` · ${job.error}` : ""}
|
||||
</span>
|
||||
)}
|
||||
<small>{String(jobId)}</small>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Button, Tag } from "@douyinfe/semi-ui-19";
|
||||
import { BacktestToolCard } from "../backtests/BacktestToolCard";
|
||||
import { CatalogToolCard } from "../research/CatalogToolCard";
|
||||
import { AlphaToolCard } from "./AlphaToolCard";
|
||||
import { JobToolCard } from "./JobToolCard";
|
||||
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>> = {
|
||||
alpha: AlphaToolCard,
|
||||
jobs: JobToolCard,
|
||||
catalog: CatalogToolCard,
|
||||
backtest: BacktestToolCard,
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
pending: "待确认",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
denied: "已拒绝",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
export function BusinessCard(
|
||||
props: ToolCardProps & {
|
||||
onDecision: (id: string, approved: boolean) => Promise<void>;
|
||||
disabled: boolean;
|
||||
},
|
||||
) {
|
||||
const { call, onDecision, disabled } = props;
|
||||
const renderer = call.presentation?.renderer ?? "generic";
|
||||
const Renderer = Object.hasOwn(renderers, renderer)
|
||||
? renderers[renderer]
|
||||
: undefined;
|
||||
const pending = call.status === "pending";
|
||||
const result = call.result ?? {};
|
||||
return (
|
||||
<section className="ai-tool-card" data-status={call.status}>
|
||||
<div className="ai-card-title">
|
||||
<strong>{call.presentation?.label ?? call.name}</strong>
|
||||
<Tag
|
||||
color={pending ? "orange" : call.status === "failed" ? "red" : "grey"}
|
||||
>
|
||||
{labels[call.status] ?? call.status}
|
||||
</Tag>
|
||||
</div>
|
||||
{Renderer ? (
|
||||
<Renderer {...props} />
|
||||
) : (
|
||||
<>
|
||||
<p>此记录暂不支持专用展示,原始结果仍可查看。</p>
|
||||
<details>
|
||||
<summary>查看业务记录</summary>
|
||||
<pre>
|
||||
{JSON.stringify(pending ? call.preview : result, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
{pending && (
|
||||
<div className="ai-approval">
|
||||
<p className="muted">确认后执行以上预览中的固定操作。</p>
|
||||
<div className="inline-actions">
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={
|
||||
disabled || !Renderer || call.presentation?.effect !== "confirm"
|
||||
}
|
||||
onClick={() => void onDecision(call.id, true)}
|
||||
>
|
||||
确认执行
|
||||
</Button>
|
||||
<Button
|
||||
type="tertiary"
|
||||
disabled={disabled}
|
||||
onClick={() => void onDecision(call.id, false)}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{typeof result.error === "string" && (
|
||||
<p className="error-text">{result.error}</p>
|
||||
)}
|
||||
{typeof result._warning === "string" && (
|
||||
<p role="status">{result._warning}</p>
|
||||
)}
|
||||
{result.denied === true && <p>已拒绝,未执行此操作。</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+15
-30
@@ -1,5 +1,5 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Research } from "../types";
|
||||
import type { Job, Research } from "../types";
|
||||
|
||||
export type ModelSettings = {
|
||||
base_url: string;
|
||||
@@ -44,6 +44,7 @@ export type ToolCard = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
presentation?: ToolPresentation;
|
||||
preview: {
|
||||
targets?: { alpha_id: string; before: Research; after: Research }[];
|
||||
job?: Record<string, unknown>;
|
||||
@@ -87,33 +88,17 @@ export const runLabels: Record<string, string> = {
|
||||
cancelled: "已停止",
|
||||
interrupted: "执行中断",
|
||||
};
|
||||
export const toolLabels: Record<string, string> = {
|
||||
get_catalog_scopes: "读取研究范围",
|
||||
search_catalog: "查询数据集与字段",
|
||||
get_catalog_detail: "读取数据详情",
|
||||
prepare_research_input: "固定研究输入",
|
||||
get_research_input: "读取固定研究输入",
|
||||
prepare_research_backtest: "构建研究候选与预览",
|
||||
get_backtest_draft: "读取候选草稿",
|
||||
get_alpha_sources: "查询 Alpha 研究来源",
|
||||
get_backtest_capabilities: "读取回测能力",
|
||||
prepare_backtest: "准备回测预览",
|
||||
get_backtest_preview: "查看回测预览",
|
||||
start_backtest: "启动固定回测",
|
||||
list_backtests: "查询回测运行",
|
||||
get_backtest: "查看回测进度",
|
||||
get_backtest_results: "读取回测结果",
|
||||
control_backtest: "控制回测运行",
|
||||
prepare_backtest_rerun: "准备重跑预览",
|
||||
search_alphas: "查询 Alpha",
|
||||
get_alpha_facets: "查询筛选选项",
|
||||
get_alpha: "读取 Alpha",
|
||||
get_alpha_pnl: "读取 PnL 缓存",
|
||||
list_jobs: "查询任务",
|
||||
get_job_status: "查看任务状态",
|
||||
update_research: "修改研究记录",
|
||||
bulk_update_research: "批量修改研究记录",
|
||||
create_sync_job: "创建同步任务",
|
||||
cancel_job: "取消任务",
|
||||
retry_job: "重试任务",
|
||||
|
||||
export type Resource = "alphas" | "datasets" | "backtests" | "jobs" | "account";
|
||||
export type ToolPresentation = {
|
||||
label: string;
|
||||
renderer: string;
|
||||
effect: "query" | "prepare" | "confirm" | "unavailable";
|
||||
refresh: Resource[];
|
||||
};
|
||||
export type ToolCardProps = {
|
||||
call: ToolCard;
|
||||
onAction: (action: UIAction) => void;
|
||||
timezone?: string;
|
||||
jobs: Job[];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PageContext, UIAction } from "./types";
|
||||
|
||||
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} 条` : ""}`,
|
||||
account: () => "上下文:个人信息页",
|
||||
backtests: () => "上下文:回测研究",
|
||||
datasets: (context) =>
|
||||
`上下文:${context.dataset_id ?? "数据目录"}${context.catalog_scope ? ` · ${context.catalog_scope.region}/${context.catalog_scope.universe}/D${context.catalog_scope.delay}` : ""}${context.template_input_id ? " · 固定研究输入" : context.unsaved_field_selection ? " · 请先保存字段选择" : ""}(不发送未保存备注)`,
|
||||
};
|
||||
|
||||
export function contextLabel(context: PageContext): string {
|
||||
return contextLabels[context.page](context);
|
||||
}
|
||||
|
||||
export function pageFromHash(hash: string): WorkspacePage {
|
||||
const page = hash.replace(/^#/, "");
|
||||
return Object.hasOwn(contextLabels, page)
|
||||
? (page as WorkspacePage)
|
||||
: "alphas";
|
||||
}
|
||||
|
||||
type Destination = {
|
||||
page?: WorkspacePage;
|
||||
chat: "open" | "close" | "responsive";
|
||||
};
|
||||
// Exhaustive action destinations prevent a new action silently falling into Alpha.
|
||||
const destinations: Record<UIAction["type"], Destination> = {
|
||||
open_conversation: { chat: "open" },
|
||||
open_research_input: { page: "datasets", chat: "close" },
|
||||
open_backtest: { page: "backtests", chat: "responsive" },
|
||||
open_backtest_preview: { page: "backtests", chat: "responsive" },
|
||||
open_alpha: { page: "alphas", chat: "responsive" },
|
||||
apply_filters: { page: "alphas", chat: "responsive" },
|
||||
};
|
||||
|
||||
export function actionDestination(action: UIAction): Destination | undefined {
|
||||
return Object.hasOwn(destinations, action.type)
|
||||
? destinations[action.type]
|
||||
: undefined;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import "./style.css";
|
||||
import { SourceDetails, sourceLabel } from "../research/SourceDetails";
|
||||
|
||||
export function BacktestPage({
|
||||
version = 0,
|
||||
active,
|
||||
suspended,
|
||||
chatOffset,
|
||||
@@ -39,6 +40,7 @@ export function BacktestPage({
|
||||
onAction,
|
||||
onContext,
|
||||
}: {
|
||||
version?: number;
|
||||
active: boolean;
|
||||
suspended: boolean;
|
||||
chatOffset: number;
|
||||
@@ -126,7 +128,7 @@ export function BacktestPage({
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [active, refresh]);
|
||||
}, [active, refresh, version]);
|
||||
useEffect(() => {
|
||||
if (!runId) return;
|
||||
let alive = true;
|
||||
@@ -154,7 +156,7 @@ export function BacktestPage({
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [runId, resultPage]);
|
||||
}, [runId, resultPage, version]);
|
||||
useEffect(() => {
|
||||
onContext({
|
||||
page: "backtests",
|
||||
|
||||
Reference in New Issue
Block a user