refactor: unify AI capabilities and workspace integration

This commit is contained in:
yuxuanhui
2026-09-08 19:28:44 +08:00
parent 3d26827b49
commit b604e6050e
23 changed files with 1821 additions and 805 deletions
+42 -46
View File
@@ -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>
+182
View File
@@ -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
View File
@@ -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>
);
}
+70
View File
@@ -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>
)}
</>
);
}
+93
View File
@@ -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
View File
@@ -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[];
};
+42
View File
@@ -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;
}
+4 -2
View File
@@ -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",
+90
View File
@@ -0,0 +1,90 @@
import { expect, test } from "@playwright/test";
for (const renderer of ["future", "toString"]) {
test(`unknown renderer ${renderer} preserves history without guessing from the tool name`, async ({
page,
}) => {
const errors: string[] = [];
const requests: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("request", (request) => requests.push(request.url()));
const call = {
id: "historical-call",
name: "future_backtest_probe",
status: "pending",
presentation: {
label: "历史业务操作",
renderer,
effect: "confirm",
refresh: [],
},
preview: {
backtest_run: { backtest_run_id: "must-not-fetch" },
preserved: "固定输入仍可检查",
},
result: null,
};
const run = {
id: "historical-run",
conversation_id: "historical-conversation",
status: "waiting_approval",
model: "synthetic",
elapsed_ms: 0,
usage: {},
tools: [call],
};
await page.route("**/api/v1/ai/settings", (route) =>
route.fulfill({
json: {
base_url: "https://model.test/v1",
model: "synthetic",
protocol: "chat_completions",
configured: true,
enabled: true,
ready: true,
test_results: {},
},
}),
);
await page.route("**/api/v1/ai/conversations", (route) =>
route.fulfill({
json: [{ id: "historical-conversation", title: "历史会话" }],
}),
);
await page.route(
"**/api/v1/ai/conversations/historical-conversation",
(route) =>
route.fulfill({
json: {
id: "historical-conversation",
title: "历史会话",
runs: [run],
messages: [
{
id: "historical-message",
role: "assistant",
parts: [{ type: "data-tool", data: call }],
},
],
},
}),
);
await page.goto("/");
await page
.getByLabel("密码", { exact: true })
.fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await page.getByRole("button", { name: "打开研究助手" }).click();
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
await expect(chat.getByText("历史业务操作", { exact: true })).toBeVisible();
await expect(chat.getByRole("button", { name: "确认执行" })).toBeDisabled();
await expect(
chat.getByRole("button", { name: "拒绝", exact: true }),
).toBeEnabled();
await chat.getByText("查看业务记录", { exact: true }).click();
await expect(chat.locator("pre")).toContainText("固定输入仍可检查");
await expect(chat.locator(".backtest-tool-summary")).toHaveCount(0);
expect(requests.some((url) => url.includes("must-not-fetch"))).toBe(false);
expect(errors).toEqual([]);
});
}
+144
View File
@@ -0,0 +1,144 @@
import { expect, test } from "@playwright/test";
const headers = { "X-WQ-Request": "1" };
const scope = {
instrument_type: "EQUITY",
region: "USA",
universe: "TOP3000",
delay: 1,
};
test("fixed input, AI approval, backtest and research source remain connected", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/#datasets");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await expect(
page.getByRole("button", { name: "打开研究助手" }),
).toBeVisible();
const config = { base_url: "https://model.test/v1", model: "test-model" };
await page.request.put("/api/v1/ai/settings", {
headers,
data: { ...config, api_key: "synthetic-key" },
});
expect(
(
await (
await page.request.post("/api/v1/ai/settings/test", { headers })
).json()
).ready,
).toBe(true);
await page.request.put("/api/v1/ai/settings", {
headers,
data: { ...config, enabled: true },
});
await page.request.put("/api/v1/account/credentials", {
headers,
data: { email: "test@example.com", password: "synthetic-password" },
});
await page.request.post("/api/v1/account/connect", { headers });
await expect
.poll(
async () =>
(await (await page.request.get("/api/v1/account")).json())
.connection_status,
)
.toBe("connected");
for (const dataset_id of [null, "TEST_FIN"]) {
const response = await page.request.post("/api/v1/catalog/sync-jobs", {
headers,
data: { scope, dataset_id },
});
expect(response.status()).toBe(202);
const job = await response.json();
await expect
.poll(
async () =>
(await (await page.request.get(`/api/v1/sync-jobs/${job.id}`)).json())
.status,
)
.toBe("completed");
}
const params = new URLSearchParams(
Object.entries(scope).map(([k, v]) => [k, String(v)]),
);
const fields = await (
await page.request.get(`/api/v1/catalog/datasets/TEST_FIN/fields?${params}`)
).json();
const saved = await page.request.post("/api/v1/catalog/inputs", {
headers,
data: {
scope,
dataset_id: "TEST_FIN",
collection_version: fields.collection_version,
selection: "all",
},
});
expect(saved.status()).toBe(201);
const input = await saved.json();
const conversation = await (
await page.request.post("/api/v1/ai/conversations", { headers })
).json();
const before = (
await (await page.request.get("/api/v1/backtests/runs")).json()
).total;
const response = await page.request.post(
`/api/v1/ai/conversations/${conversation.id}/runs`,
{
headers,
data: {
request_id: "research-navigation",
message: "研究此输入",
context: {
page: "datasets",
catalog_scope: scope,
dataset_id: "TEST_FIN",
template_input_id: input.id,
},
},
},
);
expect(response.status()).toBe(200);
await page.reload();
await page.getByRole("button", { name: "打开研究助手" }).click();
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
await expect(chat.getByRole("button", { name: "确认执行" })).toBeEnabled();
expect(
(await (await page.request.get("/api/v1/backtests/runs")).json()).total,
).toBe(before);
await chat.getByRole("button", { name: "确认执行" }).click();
await expect(chat.getByText("已保存 1/1 · 平台失败 0")).toBeVisible({
timeout: 15000,
});
await chat.getByRole("button", { name: "打开回测详情", exact: true }).click();
await expect(page.getByText("1 / 1 已保存", { exact: true })).toBeVisible();
// Follow the persisted source from the business detail, then back to its fixed input.
await chat.getByRole("button", { name: "收起研究助手" }).click();
await page
.locator(".backtest-sheet .research-source")
.getByRole("button", { name: "打开研究会话", exact: true })
.click();
await expect(chat.locator(".ai-message.user .ai-text")).toHaveText(
"研究此输入",
);
await chat
.getByRole("button", { name: "查看研究输入", exact: true })
.last()
.click();
await expect(chat).not.toBeVisible();
const inputSheet = page.getByRole("dialog", {
name: "模板输入草稿",
exact: true,
});
await expect(
inputSheet.getByRole("heading", { name: "输入草稿已保存", exact: true }),
).toBeVisible();
await inputSheet
.getByRole("button", { name: "用此输入研究", exact: true })
.click();
await expect(chat.locator(".ai-context")).toContainText("固定研究输入");
expect(errors).toEqual([]);
});