feat: add MCP research access and browser key management
This commit is contained in:
@@ -21,6 +21,7 @@ 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";
|
||||
@@ -318,6 +319,7 @@ export default function App() {
|
||||
<main
|
||||
className={`page-content ${page !== "account" ? "bounded-page" : "account-page"}`}
|
||||
>
|
||||
{page === "mcp-keys" && <MCPKeysPage />}
|
||||
{pollError && (
|
||||
<Banner
|
||||
type="warning"
|
||||
@@ -502,6 +504,7 @@ export default function App() {
|
||||
datasets: datasetContext,
|
||||
backtests: backtestContext,
|
||||
account: { page: "account" as const },
|
||||
"mcp-keys": { page: "account" as const },
|
||||
}[page]
|
||||
}
|
||||
timezone={account?.timezone}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PageContext, UIAction } from "./types";
|
||||
|
||||
export type WorkspacePage = PageContext["page"];
|
||||
const contextLabels: Record<WorkspacePage, (context: PageContext) => string> = {
|
||||
export type WorkspacePage = PageContext["page"] | "mcp-keys";
|
||||
const contextLabels: Record<
|
||||
PageContext["page"],
|
||||
(context: PageContext) => string
|
||||
> = {
|
||||
alphas: (context) =>
|
||||
`上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`,
|
||||
operators: () => "上下文:算子库",
|
||||
@@ -24,8 +27,9 @@ export function contextLabel(context: PageContext): string {
|
||||
}
|
||||
|
||||
export function pageFromHash(hash: string): WorkspacePage {
|
||||
const value = hash.replace(/^#/, "");
|
||||
const value = hash.replace(/^#/, "").split("?")[0];
|
||||
const page = value.startsWith("quantflow/") ? "quantflow" : value;
|
||||
if (page === "mcp-keys") return page;
|
||||
return Object.hasOwn(contextLabels, page)
|
||||
? (page as WorkspacePage)
|
||||
: "alphas";
|
||||
|
||||
@@ -177,6 +177,17 @@ export function BacktestPage({
|
||||
}
|
||||
}, [action]);
|
||||
|
||||
useEffect(() => {
|
||||
const locate = () => {
|
||||
if (!location.hash.startsWith("#backtests?")) return;
|
||||
const id = new URLSearchParams(location.hash.split("?")[1]).get("run_id");
|
||||
if (id && /^[a-f0-9-]{36}$/i.test(id)) openRun(id);
|
||||
};
|
||||
locate();
|
||||
window.addEventListener("hashchange", locate);
|
||||
return () => window.removeEventListener("hashchange", locate);
|
||||
}, []);
|
||||
|
||||
async function perform(work: () => Promise<void>) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
|
||||
@@ -30,6 +30,7 @@ const navigation = [
|
||||
{ id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" },
|
||||
{ id: "quantflow", label: "QuantFlow", icon: IconBeaker, group: "研究编排" },
|
||||
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
|
||||
{ id: "mcp-keys", label: "MCP Key", icon: IconCommand, group: "系统管理" },
|
||||
{ id: "account", label: "个人信息", icon: IconUser, group: "" },
|
||||
] as const;
|
||||
|
||||
@@ -172,33 +173,35 @@ export function AppSidebar({
|
||||
</div>
|
||||
)}
|
||||
<nav className="sidebar-nav" aria-label="主导航">
|
||||
{["数据与素材", "研究实验", "研究编排", "研究成果"].map((group) => (
|
||||
<div key={group}>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-heading">{group}</div>
|
||||
)}
|
||||
{navigation
|
||||
.filter((item) => item.group === group)
|
||||
.map(({ id, label, icon: Icon }) => (
|
||||
<Tooltip
|
||||
key={id}
|
||||
content={label}
|
||||
position="right"
|
||||
visible={collapsed ? undefined : false}
|
||||
>
|
||||
<button
|
||||
className={`sidebar-nav-item ${id === page ? "active" : ""}`}
|
||||
aria-label={label}
|
||||
aria-current={id === page ? "page" : undefined}
|
||||
onClick={() => navigate(id)}
|
||||
{["数据与素材", "研究实验", "研究编排", "研究成果", "系统管理"].map(
|
||||
(group) => (
|
||||
<div key={group}>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-heading">{group}</div>
|
||||
)}
|
||||
{navigation
|
||||
.filter((item) => item.group === group)
|
||||
.map(({ id, label, icon: Icon }) => (
|
||||
<Tooltip
|
||||
key={id}
|
||||
content={label}
|
||||
position="right"
|
||||
visible={collapsed ? undefined : false}
|
||||
>
|
||||
<Icon style={{ fontSize: 17 }} />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className={`sidebar-nav-item ${id === page ? "active" : ""}`}
|
||||
aria-label={label}
|
||||
aria-current={id === page ? "page" : undefined}
|
||||
onClick={() => navigate(id)}
|
||||
>
|
||||
<Icon style={{ fontSize: 17 }} />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
{!collapsed && (
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Banner, Button, Input, Toast } from "@douyinfe/semi-ui-19";
|
||||
import { api, formatTime, post } from "../api";
|
||||
import "./mcp-keys.css";
|
||||
|
||||
type Token = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
revoked_at: string | null;
|
||||
status: "active" | "expired" | "revoked" | "invalid_binding";
|
||||
};
|
||||
type TokenPage = {
|
||||
items: Token[];
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
can_create: boolean;
|
||||
};
|
||||
const scopes = [
|
||||
["research:read", "读取研究数据", "查询目录、历史、运行与结果"],
|
||||
["research:refresh", "刷新研究数据", "更新元数据和 PnL 缓存"],
|
||||
["backtests:execute", "执行回测", "提交新的回测批次"],
|
||||
["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"],
|
||||
] as const;
|
||||
const labels = {
|
||||
active: "有效",
|
||||
expired: "已过期",
|
||||
revoked: "已撤销",
|
||||
invalid_binding: "账户绑定失效",
|
||||
};
|
||||
|
||||
export function MCPKeysPage() {
|
||||
const [data, setData] = useState<TokenPage | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [version, setVersion] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [days, setDays] = useState("90");
|
||||
const [permissions, setPermissions] = useState<string[]>(["research:read"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [created, setCreated] = useState<(Token & { token: string }) | null>(
|
||||
null,
|
||||
);
|
||||
const [revoking, setRevoking] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
api<TokenPage>(`/mcp-tokens?offset=${offset}&limit=25`)
|
||||
.then((next) => {
|
||||
if (active) {
|
||||
setData(next);
|
||||
setError("");
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (active) setError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [offset, version]);
|
||||
async function create(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await post<Token & { token: string }>("/mcp-tokens", {
|
||||
name: name.trim(),
|
||||
days: Number(days),
|
||||
scopes: permissions,
|
||||
});
|
||||
setCreated(result);
|
||||
setName("");
|
||||
setOffset(0);
|
||||
setVersion((v) => v + 1);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function revoke(id: string) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await post(`/mcp-tokens/${id}/revoke`);
|
||||
if (created?.id === id) setCreated(null);
|
||||
setRevoking(null);
|
||||
setVersion((v) => v + 1);
|
||||
Toast.success("MCP Key 已撤销");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function copy(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
Toast.success("已复制");
|
||||
} catch {
|
||||
Toast.error("复制失败,请手动选择并复制");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="mcp-keys-page">
|
||||
<header>
|
||||
<div>
|
||||
<h1>MCP Key</h1>
|
||||
<p>管理 Codex 等客户端访问研究数据和执行回测的权限。</p>
|
||||
</div>
|
||||
<Button
|
||||
disabled={loading || busy}
|
||||
onClick={() => setVersion((v) => v + 1)}
|
||||
>
|
||||
刷新列表
|
||||
</Button>
|
||||
</header>
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
{data && !data.enabled && (
|
||||
<Banner
|
||||
type="warning"
|
||||
description="MCP 服务未启用。可以管理 Key,启用服务后客户端才能连接。"
|
||||
/>
|
||||
)}
|
||||
{data && !data.can_create && (
|
||||
<Banner
|
||||
type="warning"
|
||||
description="请先在个人信息页连接并确认 WorldQuant 账户身份,再创建 Key。"
|
||||
/>
|
||||
)}
|
||||
{data && (
|
||||
<section className="mcp-key-card">
|
||||
<h2>连接地址</h2>
|
||||
<div className="mcp-key-address">
|
||||
<code>{data.endpoint}</code>
|
||||
<Button onClick={() => void copy(data.endpoint)}>复制地址</Button>
|
||||
</div>
|
||||
<p>
|
||||
客户端使用 Bearer Token 认证。Key 仅用于 MCP,不提供网页管理或正式
|
||||
Alpha 提交权限。
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
{created ? (
|
||||
<section
|
||||
className="mcp-key-card mcp-key-secret"
|
||||
aria-label="新创建的 MCP Key"
|
||||
>
|
||||
<h2>Key 已创建</h2>
|
||||
<p>
|
||||
明文仅在这里显示一次。请保存到客户端凭据配置中;关闭、刷新或离开此页后无法再次查看。
|
||||
</p>
|
||||
<Input aria-label="新 Key 明文" value={created.token} readOnly />
|
||||
<div className="mcp-key-actions">
|
||||
<Button onClick={() => void copy(created.token)}>复制 Key</Button>
|
||||
<Button onClick={() => setCreated(null)}>已保存,关闭明文</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<form className="mcp-key-card" onSubmit={(event) => void create(event)}>
|
||||
<h2>创建 MCP Key</h2>
|
||||
<div className="mcp-key-fields">
|
||||
<label>
|
||||
名称
|
||||
<Input
|
||||
aria-label="Key 名称"
|
||||
value={name}
|
||||
maxLength={100}
|
||||
onChange={setName}
|
||||
placeholder="例如 Codex 研究助手"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
有效期(天)
|
||||
<input
|
||||
aria-label="有效期(天)"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
step="1"
|
||||
required
|
||||
value={days}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend>访问权限</legend>
|
||||
<div className="mcp-key-permissions">
|
||||
{scopes.map(([value, label, description]) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions.includes(value)}
|
||||
disabled={value === "research:read" || busy}
|
||||
onChange={(e) =>
|
||||
setPermissions((old) =>
|
||||
e.target.checked
|
||||
? [...old, value]
|
||||
: old.filter((item) => item !== value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
<small>
|
||||
{description}
|
||||
{value === "research:read" ? "(必选)" : ""}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
theme="solid"
|
||||
loading={busy}
|
||||
disabled={
|
||||
!data?.can_create ||
|
||||
!name.trim() ||
|
||||
!Number.isInteger(Number(days)) ||
|
||||
Number(days) < 1 ||
|
||||
Number(days) > 365
|
||||
}
|
||||
>
|
||||
创建 Key
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<section
|
||||
className="mcp-key-card"
|
||||
aria-label="Key 列表"
|
||||
aria-busy={loading}
|
||||
>
|
||||
<h2>
|
||||
已创建的 Key <span className="mcp-key-count">{data?.total ?? 0}</span>
|
||||
</h2>
|
||||
<div className="mcp-key-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>权限</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>到期时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.id}</small>
|
||||
</td>
|
||||
<td>
|
||||
{item.scopes.map((scope) => (
|
||||
<small key={scope}>
|
||||
{scopes.find(([value]) => value === scope)?.[1] ??
|
||||
scope}
|
||||
</small>
|
||||
))}
|
||||
</td>
|
||||
<td>{labels[item.status]}</td>
|
||||
<td>{formatTime(item.created_at)}</td>
|
||||
<td>{formatTime(item.expires_at)}</td>
|
||||
<td>
|
||||
{item.revoked_at ? (
|
||||
"—"
|
||||
) : revoking === item.id ? (
|
||||
<div>
|
||||
<p>撤销后新请求立即失效,已受理回测继续执行。</p>
|
||||
<Button
|
||||
type="danger"
|
||||
disabled={busy}
|
||||
onClick={() => void revoke(item.id)}
|
||||
>
|
||||
确认撤销
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => setRevoking(null)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="danger"
|
||||
disabled={busy}
|
||||
onClick={() => setRevoking(item.id)}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!loading && !data?.items.length && <p>暂无 MCP Key。</p>}
|
||||
<div className="mcp-key-actions">
|
||||
<Button
|
||||
disabled={offset === 0 || loading || busy}
|
||||
onClick={() => setOffset((v) => Math.max(0, v - 25))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span>第 {Math.floor(offset / 25) + 1} 页</span>
|
||||
<Button
|
||||
disabled={!data?.has_more || loading || busy}
|
||||
onClick={() => setOffset((v) => v + 25)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
.mcp-keys-page {
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.mcp-keys-page > header,
|
||||
.mcp-key-address {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.mcp-keys-page h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.mcp-keys-page h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.mcp-keys-page p {
|
||||
color: var(--semi-color-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.mcp-key-card {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--semi-color-bg-1);
|
||||
}
|
||||
.mcp-key-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.mcp-key-fields > label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 200px;
|
||||
}
|
||||
.mcp-key-fields input[type="number"] {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--semi-color-fill-0);
|
||||
color: var(--semi-color-text-0);
|
||||
font: inherit;
|
||||
}
|
||||
.mcp-key-card fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.mcp-key-card legend {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mcp-key-permissions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.mcp-key-permissions label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mcp-key-card small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--semi-color-text-2);
|
||||
}
|
||||
.mcp-key-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.mcp-key-table {
|
||||
overflow: auto;
|
||||
}
|
||||
.mcp-key-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
.mcp-key-table th,
|
||||
.mcp-key-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--semi-color-border);
|
||||
vertical-align: top;
|
||||
}
|
||||
.mcp-key-table th {
|
||||
color: var(--semi-color-text-2);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mcp-key-count {
|
||||
color: var(--semi-color-text-2);
|
||||
font-weight: 400;
|
||||
}
|
||||
.mcp-key-address code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.mcp-keys-page {
|
||||
padding: 12px;
|
||||
}
|
||||
.mcp-key-address {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const sourceLabel = (kind: string) =>
|
||||
({
|
||||
chatbox: "Chatbox 研究",
|
||||
manual: "手工研究",
|
||||
mcp: "MCP 研究",
|
||||
ai: "AI 研究(历史)",
|
||||
template: "模板研究",
|
||||
variant: "Alpha 变体",
|
||||
|
||||
@@ -28,7 +28,9 @@ test("draft, immutable preview, mixed-result persistence and responsive workspac
|
||||
await page.getByRole("button", { name: "新建回测" }).click();
|
||||
await page.getByLabel("运行名称", { exact: true }).fill("浏览器回测验收");
|
||||
await page.getByRole("textbox", { name: "Region", exact: true }).fill("USA");
|
||||
await page.getByRole("textbox", { name: "Universe", exact: true }).fill("TOP3000");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Universe", exact: true })
|
||||
.fill("TOP3000");
|
||||
await page.getByLabel("回测候选").fill("rank(close)\n-rank(volume)");
|
||||
await page.getByRole("button", { name: "保存草稿", exact: true }).click();
|
||||
await expect(page.getByText("草稿已保存", { exact: true })).toBeVisible();
|
||||
@@ -103,3 +105,86 @@ test("AI prepares one fixed preview, confirms once, and shows live run independe
|
||||
await chat.getByRole("button", { name: "打开回测详情", exact: true }).click();
|
||||
await expect(page.getByText("1 / 1 已保存", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("MCP direct batch appears through its run link and source filter", async ({
|
||||
page,
|
||||
}) => {
|
||||
await login(page);
|
||||
const token = (
|
||||
await (
|
||||
await page.request.post("/api/v1/test/mcp-token", { headers })
|
||||
).json()
|
||||
).token;
|
||||
const response = await page.request.post("/api/v1/mcp/", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json, text/event-stream",
|
||||
},
|
||||
data: {
|
||||
jsonrpc: "2.0",
|
||||
id: "browser-batch",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "submit_backtests",
|
||||
arguments: {
|
||||
name: "MCP 浏览器留痕",
|
||||
idempotency_key: "browser-mcp",
|
||||
duplicate_policy: "rerun",
|
||||
candidates: [
|
||||
{
|
||||
client_item_id: "browser-1",
|
||||
expression: "rank(close) + 987",
|
||||
settings: {
|
||||
instrumentType: "EQUITY",
|
||||
region: "USA",
|
||||
universe: "TOP3000",
|
||||
delay: 1,
|
||||
decay: 0,
|
||||
neutralization: "INDUSTRY",
|
||||
truncation: 0.08,
|
||||
pasteurization: "ON",
|
||||
unitHandling: "VERIFY",
|
||||
nanHandling: "OFF",
|
||||
language: "FASTEXPR",
|
||||
visualization: false,
|
||||
maxTrade: "OFF",
|
||||
maxPosition: "OFF",
|
||||
},
|
||||
},
|
||||
],
|
||||
source: {
|
||||
reference: "browser-conversation",
|
||||
hypothesis: "验证直接回测留痕",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const result = (await response.json()).result;
|
||||
expect(result.isError).toBeFalsy();
|
||||
const run = result.structuredContent;
|
||||
await page.goto(run.web_url);
|
||||
await expect(page.getByText("1 / 1 已保存", { exact: true })).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText("研究来源:MCP 研究", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("来源引用:browser-conversation", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByText("1 / 1 已保存", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("heading")
|
||||
.getByRole("button")
|
||||
.click();
|
||||
await page.getByText("全部研究来源", { exact: true }).click();
|
||||
await page.getByRole("option", { name: /MCP 研究/ }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "MCP 浏览器留痕", exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("MCP Key menu creates, hides secrets on navigation, and revokes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/#mcp-keys");
|
||||
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
||||
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "MCP Key", exact: true }),
|
||||
).toBeVisible();
|
||||
const headers = { "X-WQ-Request": "1" };
|
||||
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");
|
||||
await page.getByRole("button", { name: "刷新列表", exact: true }).click();
|
||||
await page.getByLabel("Key 名称").fill("Browser test reader");
|
||||
await page.getByRole("button", { name: "创建 Key", exact: true }).click();
|
||||
await expect(page.getByLabel("新 Key 明文")).toBeVisible();
|
||||
const secret = await page.getByLabel("新 Key 明文").inputValue();
|
||||
expect(secret).toMatch(/^wqmcp_/);
|
||||
const listed = await (await page.request.get("/api/v1/mcp-tokens")).json();
|
||||
expect(JSON.stringify(listed)).not.toContain(secret);
|
||||
const item = listed.items.find(
|
||||
(row: { name: string }) => row.name === "Browser test reader",
|
||||
);
|
||||
expect(item.scopes).toEqual(["research:read"]);
|
||||
await page.screenshot({
|
||||
path: "test-results/mcp-key-page.png",
|
||||
fullPage: true,
|
||||
mask: [page.getByLabel("新 Key 明文")],
|
||||
});
|
||||
await page
|
||||
.getByRole("navigation", { name: "主导航", exact: true })
|
||||
.getByRole("button", { name: "数据目录", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "主导航", exact: true })
|
||||
.getByRole("button", { name: "MCP Key", exact: true })
|
||||
.click();
|
||||
await expect(page.getByLabel("新 Key 明文")).toHaveCount(0);
|
||||
const row = page.getByRole("row").filter({ hasText: "Browser test reader" });
|
||||
await row.getByRole("button", { name: "撤销", exact: true }).click();
|
||||
await row.getByRole("button", { name: "确认撤销", exact: true }).click();
|
||||
await expect(row).toContainText("已撤销");
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "MCP Key", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("新 Key 明文")).toHaveCount(0);
|
||||
});
|
||||
@@ -4,7 +4,13 @@ import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: { "/api": process.env.WQ_DEV_API ?? "http://127.0.0.1:8000" },
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.WQ_DEV_API ?? "http://127.0.0.1:8000",
|
||||
// Match production proxy behavior so MCP validates the public Host.
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: { chunkSizeWarningLimit: 1500 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user