Compare commits
2 Commits
37e390a073
...
ac92b3e70b
| Author | SHA1 | Date | |
|---|---|---|---|
| ac92b3e70b | |||
| 1ba8d3ff3d |
@@ -33,6 +33,7 @@ export function AssistantDialogue({
|
|||||||
timezone?: string;
|
timezone?: string;
|
||||||
jobs: Job[];
|
jobs: Job[];
|
||||||
}) {
|
}) {
|
||||||
|
if (!messages.length) return null;
|
||||||
const sources = new Map(messages.map((message) => [message.id, message]));
|
const sources = new Map(messages.map((message) => [message.id, message]));
|
||||||
const chats: Message[] = messages.map((message) => ({
|
const chats: Message[] = messages.map((message) => ({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
} from "@douyinfe/semi-icons";
|
} from "@douyinfe/semi-icons";
|
||||||
import { contextLabel, contextReferences, researchPrompts } from "./workspace";
|
import { contextLabel, contextReferences } from "./workspace";
|
||||||
import { chatTransport } from "./transport";
|
import { chatTransport } from "./transport";
|
||||||
import { runLabels } from "./types";
|
import { runLabels } from "./types";
|
||||||
import type {
|
import type {
|
||||||
@@ -58,7 +58,7 @@ export function ChatPanel({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const root = useRef<HTMLElement>(null);
|
const root = useRef<HTMLElement>(null);
|
||||||
const input = useRef<HTMLDivElement>(null);
|
const input = useRef<HTMLDivElement>(null);
|
||||||
const bottom = useRef<HTMLDivElement>(null);
|
const messagesViewport = useRef<HTMLDivElement>(null);
|
||||||
const previousFocus = useRef<HTMLElement | null>(null);
|
const previousFocus = useRef<HTMLElement | null>(null);
|
||||||
const seenChanges = useRef(new Set<string>());
|
const seenChanges = useRef(new Set<string>());
|
||||||
const current = useRef({ conversationId, context });
|
const current = useRef({ conversationId, context });
|
||||||
@@ -206,7 +206,7 @@ export function ChatPanel({
|
|||||||
const editor = input.current?.querySelector<HTMLElement>(
|
const editor = input.current?.querySelector<HTMLElement>(
|
||||||
'[contenteditable="true"]',
|
'[contenteditable="true"]',
|
||||||
);
|
);
|
||||||
if (editor) editor.focus();
|
if (editor) editor.focus({ preventScroll: true });
|
||||||
else
|
else
|
||||||
root.current
|
root.current
|
||||||
?.querySelector<HTMLButtonElement>('button[aria-label="收起研究助手"]')
|
?.querySelector<HTMLButtonElement>('button[aria-label="收起研究助手"]')
|
||||||
@@ -226,7 +226,11 @@ export function ChatPanel({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) bottom.current?.scrollIntoView({ block: "nearest" });
|
if (!open || !messagesViewport.current) return;
|
||||||
|
// Scroll only the message region; empty conversations always start at the welcome.
|
||||||
|
messagesViewport.current.scrollTop = chat.messages.length
|
||||||
|
? messagesViewport.current.scrollHeight
|
||||||
|
: 0;
|
||||||
}, [chat.messages, open]);
|
}, [chat.messages, open]);
|
||||||
|
|
||||||
async function createConversation() {
|
async function createConversation() {
|
||||||
@@ -409,7 +413,12 @@ export function ChatPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{failure && <Banner type="danger" description={failure} />}
|
{failure && <Banner type="danger" description={failure} />}
|
||||||
<div className="ai-messages" aria-live="polite" aria-busy={streaming}>
|
<div
|
||||||
|
ref={messagesViewport}
|
||||||
|
className="ai-messages"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy={streaming}
|
||||||
|
>
|
||||||
{loading && <Spin />}
|
{loading && <Spin />}
|
||||||
{!chat.messages.length && (
|
{!chat.messages.length && (
|
||||||
<div className="ai-empty">
|
<div className="ai-empty">
|
||||||
@@ -428,7 +437,9 @@ export function ChatPanel({
|
|||||||
开始新会话
|
开始新会话
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<span>修改和任务操作会先展示预览,确认后执行。</span>
|
<p className="ai-empty-note">
|
||||||
|
修改和任务操作会先展示预览,确认后执行。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<AssistantDialogue
|
<AssistantDialogue
|
||||||
@@ -460,7 +471,6 @@ export function ChatPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={bottom} />
|
|
||||||
</div>
|
</div>
|
||||||
<footer className="ai-composer" ref={input}>
|
<footer className="ai-composer" ref={input}>
|
||||||
<div className="ai-context">{contextLabel(context)}</div>
|
<div className="ai-context">{contextLabel(context)}</div>
|
||||||
@@ -477,14 +487,6 @@ export function ChatPanel({
|
|||||||
busy={!!activeRun || streaming}
|
busy={!!activeRun || streaming}
|
||||||
onSubmit={() => void send()}
|
onSubmit={() => void send()}
|
||||||
onStop={activeRun ? () => void stop() : undefined}
|
onStop={activeRun ? () => void stop() : undefined}
|
||||||
prompts={researchPrompts(context)}
|
|
||||||
hint={
|
|
||||||
context.page === "datasets"
|
|
||||||
? context.unsaved_field_selection
|
|
||||||
? "字段选择尚未保存;请先保存为固定研究输入。未保存备注不会发送。"
|
|
||||||
: "引用随当前页面更新,未保存备注不会发送。"
|
|
||||||
: "引用随当前页面更新。修改和任务操作会先展示预览,确认后执行。"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.ai-empty {
|
.ai-empty {
|
||||||
margin: 32px 0;
|
margin: 16px 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
@@ -159,8 +159,7 @@
|
|||||||
.ai-empty .semi-button {
|
.ai-empty .semi-button {
|
||||||
margin-top: var(--space-3);
|
margin-top: var(--space-3);
|
||||||
}
|
}
|
||||||
.ai-empty span {
|
.ai-empty-note {
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
@@ -307,9 +306,14 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--semi-color-ai-background-top);
|
background: var(--semi-color-ai-background-top);
|
||||||
}
|
}
|
||||||
|
/* The outer message region owns scrolling, including welcome and run status. */
|
||||||
.ai-messages .semi-ai-chat-dialogue {
|
.ai-messages .semi-ai-chat-dialogue {
|
||||||
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
.ai-messages .semi-ai-chat-dialogue-list {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
.ai-messages .semi-ai-chat-dialogue-inner {
|
.ai-messages .semi-ai-chat-dialogue-inner {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Banner,
|
Banner,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Descriptions,
|
|
||||||
Empty,
|
Empty,
|
||||||
Divider,
|
Divider,
|
||||||
Input,
|
Input,
|
||||||
@@ -31,6 +30,7 @@ import type {
|
|||||||
Research,
|
Research,
|
||||||
ResearchState,
|
ResearchState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { DetailFieldGrid } from "./DetailFieldGrid";
|
||||||
import { PnlChart } from "./PnlChart";
|
import { PnlChart } from "./PnlChart";
|
||||||
import { SelfCorrelationPanel } from "./SelfCorrelationPanel";
|
import { SelfCorrelationPanel } from "./SelfCorrelationPanel";
|
||||||
import { SubmissionPanel } from "./SubmissionPanel";
|
import { SubmissionPanel } from "./SubmissionPanel";
|
||||||
@@ -231,19 +231,10 @@ export function AlphaDetail({
|
|||||||
>
|
>
|
||||||
<TabPane tab="概览与表达式" itemKey="overview">
|
<TabPane tab="概览与表达式" itemKey="overview">
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<Descriptions
|
<DetailFieldGrid
|
||||||
size="small"
|
|
||||||
align="left"
|
|
||||||
layout="horizontal"
|
|
||||||
column={3}
|
|
||||||
data={["region", "universe", "stage"].map((key) => ({
|
data={["region", "universe", "stage"].map((key) => ({
|
||||||
key,
|
key,
|
||||||
value: (
|
value: detail[key as keyof Detail],
|
||||||
<DescriptionValue
|
|
||||||
field={key}
|
|
||||||
value={detail[key as keyof Detail]}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
@@ -266,14 +257,10 @@ export function AlphaDetail({
|
|||||||
)}
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
<h3>回测设置</h3>
|
<h3>回测设置</h3>
|
||||||
<Descriptions
|
<DetailFieldGrid
|
||||||
size="small"
|
|
||||||
align="left"
|
|
||||||
layout="horizontal"
|
|
||||||
column={3}
|
|
||||||
data={Object.entries(detail.settings).map(([key, value]) => ({
|
data={Object.entries(detail.settings).map(([key, value]) => ({
|
||||||
key,
|
key,
|
||||||
value: <DescriptionValue field={key} value={value} />,
|
value,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -494,15 +481,8 @@ function MetricTable({
|
|||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
{entries.length ? (
|
{entries.length ? (
|
||||||
<Descriptions
|
<DetailFieldGrid
|
||||||
size="small"
|
data={entries.map(([key, value]) => ({ key, value }))}
|
||||||
align="left"
|
|
||||||
layout="horizontal"
|
|
||||||
column={3}
|
|
||||||
data={entries.map(([key, value]) => ({
|
|
||||||
key,
|
|
||||||
value: <DescriptionValue field={key} value={value} />,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="muted">未提供</p>
|
<p className="muted">未提供</p>
|
||||||
@@ -510,57 +490,3 @@ function MetricTable({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render structured metric values without flattening their field hierarchy. */
|
|
||||||
function DescriptionValue({ field, value }: { field: string; value: unknown }) {
|
|
||||||
if (value !== null && typeof value === "object") {
|
|
||||||
const entries = Object.entries(value);
|
|
||||||
return entries.length ? (
|
|
||||||
<Descriptions
|
|
||||||
className="nested-descriptions"
|
|
||||||
size="small"
|
|
||||||
align="left"
|
|
||||||
data={entries.map(([key, child]) => ({
|
|
||||||
key,
|
|
||||||
value: <DescriptionValue field={key} value={child} />,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{Array.isArray(value) ? "[]" : "{}"}</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Use field semantics for states; numbers and ordinary text remain plain values.
|
|
||||||
const stateField = /(?:^|_)(status|state|stage|result|verdict)$/.test(
|
|
||||||
field.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(),
|
|
||||||
);
|
|
||||||
const state = typeof value === "string" ? value.toUpperCase() : "";
|
|
||||||
if (
|
|
||||||
value !== null &&
|
|
||||||
value !== undefined &&
|
|
||||||
value !== "" &&
|
|
||||||
(typeof value === "boolean" ||
|
|
||||||
stateField ||
|
|
||||||
state === "ON" ||
|
|
||||||
state === "OFF")
|
|
||||||
) {
|
|
||||||
const color =
|
|
||||||
value === true ||
|
|
||||||
["PASS", "PASSED", "SUCCESS", "COMPLETED", "ON"].includes(state)
|
|
||||||
? "green"
|
|
||||||
: ["FAIL", "FAILED", "ERROR", "BLOCK"].includes(state)
|
|
||||||
? "red"
|
|
||||||
: ["WARNING", "PENDING", "PARTIAL", "REVIEW"].includes(state)
|
|
||||||
? "orange"
|
|
||||||
: "grey";
|
|
||||||
return (
|
|
||||||
<Tag size="small" color={color}>
|
|
||||||
{typeof value === "boolean"
|
|
||||||
? value
|
|
||||||
? "是"
|
|
||||||
: "否"
|
|
||||||
: displayValue(value)}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span>{displayValue(value)}</span>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Tag } from "@douyinfe/semi-ui-19";
|
||||||
|
import { displayValue } from "../api";
|
||||||
|
|
||||||
|
/** Keep structured values on their own row at every nesting level. */
|
||||||
|
export function DetailFieldGrid({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: { key: string; value: unknown }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="detail-field-container">
|
||||||
|
<dl className="detail-field-grid">
|
||||||
|
{data.map(({ key, value }) => {
|
||||||
|
const structured = value !== null && typeof value === "object";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={structured ? "detail-field-group" : "detail-field"}
|
||||||
|
key={key}
|
||||||
|
>
|
||||||
|
<dt>{key}</dt>
|
||||||
|
<dd>
|
||||||
|
{structured ? (
|
||||||
|
Object.keys(value).length ? (
|
||||||
|
<DetailFieldGrid
|
||||||
|
data={Object.entries(value).map(([name, child]) => ({
|
||||||
|
key: Array.isArray(value) ? `[${name}]` : name,
|
||||||
|
value: child,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="muted">
|
||||||
|
{Array.isArray(value) ? "[]" : "{}"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<FieldValue field={key} value={value} />
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldValue({ field, value }: { field: string; value: unknown }) {
|
||||||
|
// Use field semantics for states; numbers and ordinary text remain plain values.
|
||||||
|
const stateField = /(?:^|_)(status|state|stage|result|verdict)$/.test(
|
||||||
|
field.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(),
|
||||||
|
);
|
||||||
|
const state = typeof value === "string" ? value.toUpperCase() : "";
|
||||||
|
if (
|
||||||
|
value !== null &&
|
||||||
|
value !== undefined &&
|
||||||
|
value !== "" &&
|
||||||
|
(typeof value === "boolean" ||
|
||||||
|
stateField ||
|
||||||
|
state === "ON" ||
|
||||||
|
state === "OFF")
|
||||||
|
) {
|
||||||
|
const color =
|
||||||
|
value === true ||
|
||||||
|
["PASS", "PASSED", "SUCCESS", "COMPLETED", "ON"].includes(state)
|
||||||
|
? "green"
|
||||||
|
: ["FAIL", "FAILED", "ERROR", "BLOCK"].includes(state)
|
||||||
|
? "red"
|
||||||
|
: ["WARNING", "PENDING", "PARTIAL", "REVIEW"].includes(state)
|
||||||
|
? "orange"
|
||||||
|
: "grey";
|
||||||
|
return (
|
||||||
|
<Tag size="small" color={color}>
|
||||||
|
{typeof value === "boolean"
|
||||||
|
? value
|
||||||
|
? "是"
|
||||||
|
: "否"
|
||||||
|
: displayValue(value)}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>{displayValue(value)}</span>;
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
|
||||||
Empty,
|
Empty,
|
||||||
Spin,
|
Spin,
|
||||||
Table,
|
Table,
|
||||||
@@ -10,6 +9,7 @@ import {
|
|||||||
Toast,
|
Toast,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} from "@douyinfe/semi-ui-19";
|
||||||
import { api, correlationLabels, formatNumber, formatTime, post } from "../api";
|
import { api, correlationLabels, formatNumber, formatTime, post } from "../api";
|
||||||
|
import { DetailFieldGrid } from "./DetailFieldGrid";
|
||||||
import type { CorrelationResult } from "../types";
|
import type { CorrelationResult } from "../types";
|
||||||
|
|
||||||
export function SelfCorrelationPanel({
|
export function SelfCorrelationPanel({
|
||||||
@@ -108,11 +108,7 @@ export function SelfCorrelationPanel({
|
|||||||
计算于 {formatTime(result.calculated_at, timezone)}
|
计算于 {formatTime(result.calculated_at, timezone)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Descriptions
|
<DetailFieldGrid
|
||||||
size="small"
|
|
||||||
align="left"
|
|
||||||
layout="horizontal"
|
|
||||||
column={3}
|
|
||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
key: "最大相关系数",
|
key: "最大相关系数",
|
||||||
|
|||||||
+53
-12
@@ -919,20 +919,61 @@ label:not(.semi-checkbox):not(.semi-radio) {
|
|||||||
.alpha-detail .semi-divider-horizontal {
|
.alpha-detail .semi-divider-horizontal {
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
.alpha-detail .semi-descriptions table {
|
/* Container queries follow the available drawer width, including chat offsets. */
|
||||||
table-layout: fixed;
|
.detail-field-container {
|
||||||
width: 100%;
|
container-type: inline-size;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.alpha-detail .semi-descriptions th,
|
.detail-field-grid {
|
||||||
.alpha-detail .semi-descriptions td {
|
display: grid;
|
||||||
padding-bottom: 6px;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
vertical-align: top;
|
gap: 8px 24px;
|
||||||
overflow-wrap: anywhere;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.alpha-detail .nested-descriptions {
|
.detail-field {
|
||||||
margin-bottom: 0;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.alpha-detail .nested-descriptions th {
|
.detail-field-grid dt,
|
||||||
width: 45%;
|
.detail-field-grid dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.detail-field > dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.detail-field-group {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-width: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.detail-field-group > dt {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.detail-field-group .detail-field-group {
|
||||||
|
border-left: 2px solid var(--line);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
@container (max-width: 760px) {
|
||||||
|
.detail-field-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@container (max-width: 480px) {
|
||||||
|
.detail-field-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ width: 1440, height: 850 },
|
||||||
|
{ width: 390, height: 650 },
|
||||||
|
]) {
|
||||||
|
test(`assistant opens with welcome and composer in view at ${viewport.width}`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.route("**/api/v1/ai/conversations", (route) =>
|
||||||
|
route.fulfill({ json: [] }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/v1/account", async (route) => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
await route.fulfill({
|
||||||
|
json: { ...(await response.json()), theme: "dark" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (viewport.width === 1440)
|
||||||
|
await page.route("**/api/v1/ai/settings", (route) =>
|
||||||
|
route.fulfill({ json: { enabled: true, ready: true } }),
|
||||||
|
);
|
||||||
|
await page.goto("/");
|
||||||
|
await page
|
||||||
|
.getByLabel("密码", { exact: true })
|
||||||
|
.fill("browser-test-password");
|
||||||
|
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||||
|
await page.evaluate(() => document.body.setAttribute("theme-mode", "dark"));
|
||||||
|
await page.getByRole("button", { name: "切换研究助手" }).click();
|
||||||
|
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
chat.getByRole("heading", { name: "从当前研究出发" }),
|
||||||
|
).toBeInViewport();
|
||||||
|
await expect(
|
||||||
|
chat.getByRole("button", { name: "发送", exact: true }),
|
||||||
|
).toBeInViewport();
|
||||||
|
await expect(page.locator("body")).toHaveAttribute("theme-mode", "dark");
|
||||||
|
await page.screenshot({
|
||||||
|
path: `../output/playwright/ai-layout-${viewport.width}.png`,
|
||||||
|
});
|
||||||
|
const start = chat.getByRole("button", { name: "开始新会话", exact: true });
|
||||||
|
expect(
|
||||||
|
await start.evaluate((button) => {
|
||||||
|
const outer = button.getBoundingClientRect();
|
||||||
|
return [...button.querySelectorAll("span")].every((span) => {
|
||||||
|
const inner = span.getBoundingClientRect();
|
||||||
|
return inner.top >= outer.top && inner.bottom <= outer.bottom;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
await expect(
|
||||||
|
chat.locator(".research-ai-prompts, .research-ai-hint"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
expect(
|
||||||
|
await chat.evaluate((el) => el.scrollHeight <= el.clientHeight),
|
||||||
|
).toBe(true);
|
||||||
|
await chat.getByRole("button", { name: "收起研究助手" }).click();
|
||||||
|
await page.getByRole("button", { name: "切换研究助手" }).click();
|
||||||
|
await expect(
|
||||||
|
chat.getByRole("heading", { name: "从当前研究出发" }),
|
||||||
|
).toBeInViewport();
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.body.setAttribute("theme-mode", "light"),
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `../output/playwright/ai-layout-${viewport.width}-light.png`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -84,13 +84,13 @@ test("model settings, text stream, contextual query, confirmation and preserved
|
|||||||
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
|
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
|
||||||
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0001");
|
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0001");
|
||||||
await page.getByRole("button", { name: "查询", exact: true }).click();
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
|
// Global tools sit below drawers; open the assistant before the Alpha detail.
|
||||||
|
await page.getByRole("button", { name: "切换研究助手" }).click();
|
||||||
await page.locator(".alpha-link").click();
|
await page.locator(".alpha-link").click();
|
||||||
await page.getByRole("tab", { name: "研究信息" }).click();
|
await page.getByRole("tab", { name: "研究信息" }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole("textbox", { name: "研究备注" })
|
.getByRole("textbox", { name: "研究备注" })
|
||||||
.fill("尚未保存的人工研究草稿");
|
.fill("尚未保存的人工研究草稿");
|
||||||
// A global launcher must remain reachable when the detail mask is open.
|
|
||||||
await page.getByRole("button", { name: "切换研究助手" }).click();
|
|
||||||
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
|
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
|
||||||
await chat.getByRole("button", { name: "新会话", exact: true }).click();
|
await chat.getByRole("button", { name: "新会话", exact: true }).click();
|
||||||
await expect(chat.locator(".research-ai-reference")).toContainText(
|
await expect(chat.locator(".research-ai-reference")).toContainText(
|
||||||
@@ -430,11 +430,6 @@ test("AI composer preserves literal text, IME input and drafts while generating"
|
|||||||
)
|
)
|
||||||
sent.push(request.postDataJSON().message);
|
sent.push(request.postDataJSON().message);
|
||||||
});
|
});
|
||||||
await chat
|
|
||||||
.getByRole("button", { name: "查询当前研究对象与已有结果", exact: true })
|
|
||||||
.click();
|
|
||||||
await expect(input).toHaveText("查询当前研究对象与已有结果");
|
|
||||||
expect(sent).toEqual([]);
|
|
||||||
await input.fill("SLOW <字段>");
|
await input.fill("SLOW <字段>");
|
||||||
await input.dispatchEvent("keydown", {
|
await input.dispatchEvent("keydown", {
|
||||||
key: "Enter",
|
key: "Enter",
|
||||||
|
|||||||
@@ -95,11 +95,31 @@ test("submission tabs, day selection, local correlation and reload", async ({
|
|||||||
await page.route("**/api/v1/alphas/TEST0004", async (route) => {
|
await page.route("**/api/v1/alphas/TEST0004", async (route) => {
|
||||||
const response = await route.fetch();
|
const response = await route.fetch();
|
||||||
const detail = await response.json();
|
const detail = await response.json();
|
||||||
detail.is_metrics.nested = {
|
detail.is_metrics.glbAmer = {
|
||||||
yearly: { sharpe: 1.25, status: "PASS" },
|
yearly: { sharpe: 1.25, status: "PASS" },
|
||||||
samples: 120,
|
samples: 120,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
};
|
};
|
||||||
|
detail.is_metrics.glbApac = {
|
||||||
|
pnl: 439696,
|
||||||
|
bookSize: 20000000,
|
||||||
|
longCount: 215,
|
||||||
|
turnover: 0.0242,
|
||||||
|
sharpe: 0.85,
|
||||||
|
};
|
||||||
|
detail.is_metrics.glbEmea = { pnl: 571980, fitness: 0.19, sharpe: 0.72 };
|
||||||
|
detail.is_metrics.investabilityConstrained = {
|
||||||
|
pnl: 3696382,
|
||||||
|
longCount: 890,
|
||||||
|
turnover: 0.0919,
|
||||||
|
};
|
||||||
|
detail.is_metrics.history = [
|
||||||
|
{ status: "PASS", sharpe: 1.1 },
|
||||||
|
{ status: "FAIL", sharpe: 0.2 },
|
||||||
|
];
|
||||||
|
detail.is_metrics.emptyObject = {};
|
||||||
|
detail.is_metrics.emptyArray = [];
|
||||||
|
detail.is_metrics.unavailable = null;
|
||||||
await route.fulfill({ response, json: detail });
|
await route.fulfill({ response, json: detail });
|
||||||
});
|
});
|
||||||
await page.locator(".alpha-link").click();
|
await page.locator(".alpha-link").click();
|
||||||
@@ -123,31 +143,73 @@ test("submission tabs, day selection, local correlation and reload", async ({
|
|||||||
const metrics = sheet.locator(".detail-section").filter({
|
const metrics = sheet.locator(".detail-section").filter({
|
||||||
has: page.getByRole("heading", { name: "IS 指标", exact: true }),
|
has: page.getByRole("heading", { name: "IS 指标", exact: true }),
|
||||||
});
|
});
|
||||||
await expect(metrics.locator(".nested-descriptions").first()).toContainText(
|
|
||||||
"1.25",
|
|
||||||
);
|
|
||||||
await expect(
|
await expect(
|
||||||
metrics
|
metrics
|
||||||
.locator(".semi-descriptions-horizontal > table > tbody > tr")
|
.locator(".detail-field-group")
|
||||||
.first()
|
.filter({ has: page.locator("dt", { hasText: "yearly" }) })
|
||||||
.locator(":scope > th"),
|
.first(),
|
||||||
).toHaveCount(3);
|
).toContainText("1.25");
|
||||||
await expect(
|
await expect(
|
||||||
metrics.locator(".semi-tag").filter({ hasText: "PASS" }),
|
metrics.locator(".semi-tag").filter({ hasText: "PASS" }).first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
metrics.locator(".semi-tag").filter({ hasText: "否" }),
|
metrics.locator(".semi-tag").filter({ hasText: "否" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(
|
await expect(metrics).toContainText("[]");
|
||||||
await metrics
|
await expect(metrics).toContainText("{}");
|
||||||
.locator("th, td")
|
await expect(metrics).toContainText("未提供");
|
||||||
.evaluateAll((cells) =>
|
for (const [viewportWidth, columns] of [
|
||||||
cells.every((cell) => getComputedStyle(cell).textAlign === "left"),
|
[1440, 3],
|
||||||
),
|
[900, 2],
|
||||||
).toBe(true);
|
[600, 1],
|
||||||
await page.screenshot({
|
[390, 1],
|
||||||
path: "../output/playwright/alpha-detail-metrics.png",
|
]) {
|
||||||
});
|
await page.setViewportSize({ width: viewportWidth, height: 1000 });
|
||||||
|
const grid = metrics.locator(
|
||||||
|
":scope > .detail-field-container > .detail-field-grid",
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
grid.evaluate(
|
||||||
|
(element) =>
|
||||||
|
getComputedStyle(element).gridTemplateColumns.split(" ").length,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(columns);
|
||||||
|
const layout = await metrics.evaluate((element) => {
|
||||||
|
const groups = Array.from(
|
||||||
|
element.querySelectorAll<HTMLElement>(".detail-field-group"),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
fullRows: groups.every((group) => {
|
||||||
|
const box = group.getBoundingClientRect();
|
||||||
|
const parent = group.parentElement!.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
Math.abs(box.x - parent.x) < 1 &&
|
||||||
|
Math.abs(box.width - parent.width) < 1
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
leftAligned: Array.from(element.querySelectorAll("dt, dd")).every(
|
||||||
|
(cell) => getComputedStyle(cell).textAlign === "left",
|
||||||
|
),
|
||||||
|
noOverflow: Array.from(
|
||||||
|
element.querySelectorAll<HTMLElement>(
|
||||||
|
".detail-field-container, dt, dd",
|
||||||
|
),
|
||||||
|
).every((cell) => cell.scrollWidth <= cell.clientWidth + 1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(layout).toEqual({
|
||||||
|
fullRows: true,
|
||||||
|
leftAligned: true,
|
||||||
|
noOverflow: true,
|
||||||
|
});
|
||||||
|
await page.screenshot({
|
||||||
|
path: `../output/playwright/alpha-grid-${viewportWidth}.png`,
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
await sheet.getByRole("tab", { name: "变体关系", exact: true }).click();
|
await sheet.getByRole("tab", { name: "变体关系", exact: true }).click();
|
||||||
await expect(
|
await expect(
|
||||||
sheet.getByRole("button", { name: "研究此 Alpha 的变体" }),
|
sheet.getByRole("button", { name: "研究此 Alpha 的变体" }),
|
||||||
|
|||||||
Reference in New Issue
Block a user