feat: add AI research chatbot with confirmed business tools
This commit is contained in:
+93
-5
@@ -23,6 +23,8 @@ import type { Account, Job } from "./types";
|
||||
import { AccountPage } from "./pages/AccountPage";
|
||||
import { AlphaPage } from "./pages/AlphaPage";
|
||||
import { JobPanel } from "./components/JobPanel";
|
||||
import { ChatPanel } from "./ai/ChatPanel";
|
||||
import type { PageContext, UIAction } from "./ai/types";
|
||||
|
||||
export default function App() {
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
@@ -34,6 +36,35 @@ export default function App() {
|
||||
const [showJobs, setShowJobs] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [pollError, setPollError] = useState("");
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [chatWidth, setChatWidth] = useState(420);
|
||||
const [viewport, setViewport] = useState(window.innerWidth);
|
||||
const [alphaContext, setAlphaContext] = useState<PageContext>({
|
||||
page: "alphas",
|
||||
});
|
||||
const [aiAction, setAIAction] = useState<UIAction | null>(null);
|
||||
const chatOffset = viewport >= 1440 && chatOpen ? chatWidth : 0;
|
||||
const focusBusiness = useCallback(() => {
|
||||
if (window.innerWidth < 1440) setChatOpen(false);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const resize = () => setViewport(window.innerWidth);
|
||||
window.addEventListener("resize", resize);
|
||||
return () => window.removeEventListener("resize", resize);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--chat-space",
|
||||
`${chatOffset}px`,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--chat-width",
|
||||
`${chatWidth}px`,
|
||||
);
|
||||
return () => {
|
||||
document.documentElement.style.setProperty("--chat-space", "0px");
|
||||
};
|
||||
}, [chatOffset, chatWidth]);
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [nextAccount, nextJobs] = await Promise.all([
|
||||
@@ -56,6 +87,8 @@ export default function App() {
|
||||
setAuthenticated(false);
|
||||
setAccount(null);
|
||||
setJobs([]);
|
||||
setChatOpen(false);
|
||||
setShowJobs(false);
|
||||
};
|
||||
window.addEventListener("session-expired", expired);
|
||||
const hash = () =>
|
||||
@@ -103,6 +136,12 @@ export default function App() {
|
||||
try {
|
||||
await post("/auth/logout");
|
||||
setAuthenticated(false);
|
||||
setChatOpen(false);
|
||||
setShowJobs(false);
|
||||
setAccount(null);
|
||||
setJobs([]);
|
||||
setAIAction(null);
|
||||
setAlphaContext({ page: "alphas" });
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
}
|
||||
@@ -174,11 +213,21 @@ export default function App() {
|
||||
<Button
|
||||
theme="borderless"
|
||||
icon={<IconBell />}
|
||||
onClick={() => setShowJobs(true)}
|
||||
onClick={() => {
|
||||
focusBusiness();
|
||||
setShowJobs(true);
|
||||
}}
|
||||
>
|
||||
同步任务
|
||||
</Button>
|
||||
</Badge>
|
||||
<Button
|
||||
aria-label="切换研究助手"
|
||||
theme={chatOpen ? "light" : "borderless"}
|
||||
onClick={() => setChatOpen(!chatOpen)}
|
||||
>
|
||||
AI 助手
|
||||
</Button>
|
||||
<div className="top-divider" />
|
||||
<Avatar size="small" color="purple">
|
||||
{account?.display_name.slice(0, 1) || "研"}
|
||||
@@ -201,25 +250,35 @@ export default function App() {
|
||||
description={`后台状态暂时不可用:${pollError}`}
|
||||
/>
|
||||
)}
|
||||
{page === "account" ? (
|
||||
<div hidden={page !== "account"}>
|
||||
<AccountPage
|
||||
account={account}
|
||||
onChange={actionDone}
|
||||
onTask={taskCreated}
|
||||
/>
|
||||
) : (
|
||||
</div>
|
||||
<div hidden={page !== "alphas"}>
|
||||
<AlphaPage
|
||||
taskPanelOpen={showJobs}
|
||||
account={account}
|
||||
version={`${refreshKey}:${completedVersion}`}
|
||||
onTask={taskCreated}
|
||||
onAccount={() => changePage("account")}
|
||||
active={page === "alphas"}
|
||||
overlaySuspended={
|
||||
page !== "alphas" || (viewport < 1440 && chatOpen)
|
||||
}
|
||||
chatOffset={chatOffset}
|
||||
onContext={setAlphaContext}
|
||||
action={aiAction}
|
||||
onOverlay={focusBusiness}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<JobPanel
|
||||
visible={showJobs}
|
||||
visible={showJobs && !(viewport < 1440 && chatOpen)}
|
||||
chatOffset={chatOffset}
|
||||
jobs={jobs}
|
||||
timezone={account?.timezone}
|
||||
onClose={() => setShowJobs(false)}
|
||||
@@ -229,6 +288,35 @@ export default function App() {
|
||||
changePage("account");
|
||||
}}
|
||||
/>
|
||||
{!chatOpen && (
|
||||
<Button
|
||||
className="ai-launcher"
|
||||
aria-label="打开研究助手"
|
||||
theme="solid"
|
||||
onClick={() => setChatOpen(true)}
|
||||
>
|
||||
AI 研究助手
|
||||
</Button>
|
||||
)}
|
||||
<ChatPanel
|
||||
jobs={jobs}
|
||||
open={chatOpen}
|
||||
width={chatWidth}
|
||||
onWidth={setChatWidth}
|
||||
onClose={() => setChatOpen(false)}
|
||||
context={page === "alphas" ? alphaContext : { page: "account" }}
|
||||
timezone={account?.timezone}
|
||||
onSettings={() => {
|
||||
focusBusiness();
|
||||
changePage("account");
|
||||
}}
|
||||
onChanged={actionDone}
|
||||
onAction={(action) => {
|
||||
focusBusiness();
|
||||
changePage("alphas");
|
||||
setAIAction(action);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</LocaleProvider>
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Select,
|
||||
Spin,
|
||||
Tag,
|
||||
TextArea,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
api,
|
||||
formatNumber,
|
||||
formatTime,
|
||||
jobLabels,
|
||||
jobStateLabels,
|
||||
post,
|
||||
stateLabels,
|
||||
} from "../api";
|
||||
import { PnlChart } from "../components/PnlChart";
|
||||
import type { Alpha, Job, Pnl, Research } from "../types";
|
||||
import { chatTransport } from "./transport";
|
||||
import { runLabels, toolLabels } from "./types";
|
||||
import type {
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
ConversationDetail,
|
||||
ModelSettings,
|
||||
PageContext,
|
||||
RunSnapshot,
|
||||
ToolCard,
|
||||
UIAction,
|
||||
} from "./types";
|
||||
|
||||
export function ChatPanel({
|
||||
open,
|
||||
context,
|
||||
onClose,
|
||||
onAction,
|
||||
onChanged,
|
||||
onSettings,
|
||||
width,
|
||||
onWidth,
|
||||
timezone,
|
||||
jobs,
|
||||
}: {
|
||||
open: boolean;
|
||||
context: PageContext;
|
||||
onClose: () => void;
|
||||
onAction: (action: UIAction) => void;
|
||||
onChanged: () => void;
|
||||
onSettings: () => void;
|
||||
width: number;
|
||||
onWidth: (width: number) => void;
|
||||
timezone?: string;
|
||||
jobs: Job[];
|
||||
}) {
|
||||
const [settings, setSettings] = useState<ModelSettings | null>(null);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [conversationId, setConversationId] = useState("");
|
||||
const [runs, setRuns] = useState<RunSnapshot[]>([]);
|
||||
const [text, setText] = useState("");
|
||||
const [failure, setFailure] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const root = useRef<HTMLElement>(null);
|
||||
const input = useRef<HTMLDivElement>(null);
|
||||
const bottom = useRef<HTMLDivElement>(null);
|
||||
const previousFocus = useRef<HTMLElement | null>(null);
|
||||
const seenWrites = 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;
|
||||
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",
|
||||
].includes(call.name) &&
|
||||
!seenWrites.current.has(call.id)
|
||||
) {
|
||||
seenWrites.current.add(call.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) changedRef.current();
|
||||
}, []);
|
||||
const transport = useMemo(() => chatTransport(() => current.current), []);
|
||||
const chat = useChat<ChatMessage>({
|
||||
id: conversationId || "unselected",
|
||||
transport,
|
||||
experimental_throttle: 50,
|
||||
onData: (part) => {
|
||||
if (part.type === "data-run") {
|
||||
const next = part.data;
|
||||
if (next.conversation_id !== current.current.conversationId) return;
|
||||
observeWrites([next]);
|
||||
setRuns((items) => {
|
||||
const old = items.find((run) => run.id === next.id);
|
||||
const merged = {
|
||||
...old,
|
||||
...next,
|
||||
tools: next.tools ?? old?.tools ?? [],
|
||||
};
|
||||
return [...items.filter((run) => run.id !== next.id), merged];
|
||||
});
|
||||
}
|
||||
},
|
||||
onFinish: async () => {
|
||||
await refreshRef.current();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFailure(error.message);
|
||||
void refreshRef.current();
|
||||
},
|
||||
});
|
||||
const refreshConversation = useCallback(async () => {
|
||||
const id = current.current.conversationId;
|
||||
if (!id) return;
|
||||
try {
|
||||
const detail = await api<ConversationDetail>(`/ai/conversations/${id}`);
|
||||
if (current.current.conversationId !== id) return;
|
||||
chat.setMessages(detail.messages);
|
||||
setRuns(detail.runs);
|
||||
observeWrites(detail.runs);
|
||||
setConversations((items) =>
|
||||
items.map((item) =>
|
||||
item.id === id ? { id, title: detail.title } : item,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setFailure((e as Error).message);
|
||||
}
|
||||
}, [chat.setMessages, observeWrites]);
|
||||
refreshRef.current = refreshConversation;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const loadSettings = () =>
|
||||
api<ModelSettings>("/ai/settings")
|
||||
.then((value) => {
|
||||
if (active) setSettings(value);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setFailure(e.message);
|
||||
});
|
||||
void loadSettings();
|
||||
api<Conversation[]>("/ai/conversations")
|
||||
.then((items) => {
|
||||
if (active) {
|
||||
setConversations(items);
|
||||
setConversationId(items[0]?.id ?? "");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setFailure(e.message);
|
||||
});
|
||||
window.addEventListener("ai-settings-changed", loadSettings);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("ai-settings-changed", loadSettings);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setRuns([]);
|
||||
setFailure("");
|
||||
setLoading(true);
|
||||
void refreshConversation().finally(() => setLoading(false));
|
||||
}, [conversationId, refreshConversation]);
|
||||
const streaming = chat.status === "streaming" || chat.status === "submitted";
|
||||
const activeRun = runs.find((run) =>
|
||||
["running", "waiting_approval"].includes(run.status),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!conversationId || streaming || activeRun?.status !== "running") return;
|
||||
const timer = window.setInterval(() => void refreshRef.current(), 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [conversationId, streaming, activeRun?.status]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previousFocus.current = document.activeElement as HTMLElement;
|
||||
input.current?.querySelector("textarea")?.focus();
|
||||
return () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (
|
||||
previousFocus.current?.isConnected &&
|
||||
previousFocus.current !== document.body
|
||||
)
|
||||
previousFocus.current.focus();
|
||||
else
|
||||
document
|
||||
.querySelector<HTMLElement>('[aria-label="打开研究助手"]')
|
||||
?.focus();
|
||||
});
|
||||
};
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (open) bottom.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [chat.messages, open]);
|
||||
|
||||
async function createConversation() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await chat.stop();
|
||||
const value = await post<Conversation>("/ai/conversations");
|
||||
setConversations((items) => [value, ...items]);
|
||||
setConversationId(value.id);
|
||||
setText("");
|
||||
} catch (e) {
|
||||
setFailure((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
async function send() {
|
||||
if (!text.trim() || !conversationId || activeRun || streaming) return;
|
||||
const message = text;
|
||||
setText("");
|
||||
setFailure("");
|
||||
chat.clearError();
|
||||
try {
|
||||
await chat.sendMessage({ text: message });
|
||||
} catch (e) {
|
||||
setFailure((e as Error).message);
|
||||
}
|
||||
}
|
||||
async function decide(id: string, approved: boolean) {
|
||||
setFailure("");
|
||||
chat.clearError();
|
||||
try {
|
||||
await chat.sendMessage(undefined, {
|
||||
body: { decision: { id, approved } },
|
||||
});
|
||||
} catch (e) {
|
||||
setFailure((e as Error).message);
|
||||
}
|
||||
}
|
||||
async function stop() {
|
||||
if (!activeRun) return;
|
||||
try {
|
||||
await post(`/ai/runs/${activeRun.id}/cancel`);
|
||||
await chat.stop();
|
||||
await refreshConversation();
|
||||
changedRef.current();
|
||||
} catch (e) {
|
||||
setFailure((e as Error).message);
|
||||
}
|
||||
}
|
||||
const cards = new Map(
|
||||
runs.flatMap((run) => run.tools ?? []).map((call) => [call.id, call]),
|
||||
);
|
||||
const lastRun = runs.at(-1);
|
||||
const ready = !!settings?.enabled && settings.ready;
|
||||
return (
|
||||
<aside
|
||||
ref={root}
|
||||
hidden={!open}
|
||||
className="ai-chat"
|
||||
aria-label="AI 研究助手"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
if (event.key === "Tab" && window.innerWidth < 1440) {
|
||||
const elements = [
|
||||
...(root.current?.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), textarea:not(:disabled), input:not(:disabled), [tabindex="0"]',
|
||||
) ?? []),
|
||||
].filter((el) => el.offsetParent !== null);
|
||||
if (event.shiftKey && document.activeElement === elements[0]) {
|
||||
event.preventDefault();
|
||||
elements.at(-1)?.focus();
|
||||
} else if (
|
||||
!event.shiftKey &&
|
||||
document.activeElement === elements.at(-1)
|
||||
) {
|
||||
event.preventDefault();
|
||||
elements[0]?.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="ai-resize"
|
||||
role="separator"
|
||||
aria-label="调整助手宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={360}
|
||||
aria-valuemax={640}
|
||||
aria-valuenow={width}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowLeft") onWidth(Math.min(640, width + 20));
|
||||
if (event.key === "ArrowRight") onWidth(Math.max(360, width - 20));
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId))
|
||||
onWidth(
|
||||
Math.max(360, Math.min(640, window.innerWidth - event.clientX)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<header className="ai-header">
|
||||
<div>
|
||||
<strong>AI 研究助手</strong>
|
||||
<span>与你一起查看、分析和记录</span>
|
||||
</div>
|
||||
<Button aria-label="收起研究助手" theme="borderless" onClick={onClose}>
|
||||
收起
|
||||
</Button>
|
||||
</header>
|
||||
<div className="ai-conversations">
|
||||
<Select
|
||||
aria-label="选择聊天会话"
|
||||
value={conversationId || undefined}
|
||||
placeholder="选择会话"
|
||||
optionList={conversations.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.title,
|
||||
}))}
|
||||
onChange={async (value) => {
|
||||
await chat.stop();
|
||||
setConversationId(String(value));
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button onClick={() => void createConversation()} disabled={loading}>
|
||||
新会话
|
||||
</Button>
|
||||
</div>
|
||||
{!ready && (
|
||||
<Banner
|
||||
type="info"
|
||||
description={
|
||||
<span>
|
||||
配置并测试模型服务后即可聊天。
|
||||
<button className="text-link" onClick={onSettings}>
|
||||
前往配置
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{failure && <Banner type="danger" description={failure} />}
|
||||
<div className="ai-messages" aria-live="polite" aria-busy={streaming}>
|
||||
{loading && <Spin />}
|
||||
{!chat.messages.length && (
|
||||
<div className="ai-empty">
|
||||
<strong>从当前研究出发</strong>
|
||||
<p>可以让我筛选 Alpha、解释已有指标,或提出研究记录修改。</p>
|
||||
{!conversationId && (
|
||||
<Button
|
||||
onClick={() => void createConversation()}
|
||||
disabled={loading}
|
||||
>
|
||||
开始新会话
|
||||
</Button>
|
||||
)}
|
||||
<span>修改和任务操作会先展示预览,确认后执行。</span>
|
||||
</div>
|
||||
)}
|
||||
{chat.messages.map((message) => (
|
||||
<article className={`ai-message ${message.role}`} key={message.id}>
|
||||
<span className="ai-role">
|
||||
{message.role === "user" ? "你" : "研究助手"}
|
||||
</span>
|
||||
{message.parts.map((part, index) =>
|
||||
part.type === "text" ? (
|
||||
<div className="ai-text" key={index}>
|
||||
{part.text}
|
||||
</div>
|
||||
) : part.type === "data-tool" ? (
|
||||
<BusinessCard
|
||||
key={part.id ?? index}
|
||||
call={cards.get(part.data.id) ?? part.data}
|
||||
onDecision={decide}
|
||||
disabled={
|
||||
streaming || activeRun?.status !== "waiting_approval"
|
||||
}
|
||||
onAction={onAction}
|
||||
timezone={timezone}
|
||||
jobs={jobs}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{lastRun && (
|
||||
<div className="ai-run-state">
|
||||
<Tag color={lastRun.status === "failed" ? "red" : "grey"}>
|
||||
{runLabels[lastRun.status]}
|
||||
</Tag>
|
||||
{lastRun.error && <p className="error-text">{lastRun.error}</p>}
|
||||
{lastRun.usage && (
|
||||
<small>
|
||||
{lastRun.model} · {(lastRun.elapsed_ms / 1000).toFixed(1)} 秒 ·{" "}
|
||||
{lastRun.usage.token_usage_known &&
|
||||
(lastRun.usage.input_tokens ?? 0) +
|
||||
(lastRun.usage.output_tokens ?? 0) >
|
||||
0
|
||||
? `${lastRun.usage.input_tokens} 输入 / ${lastRun.usage.output_tokens} 输出 tokens`
|
||||
: "用量未提供"}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
<footer className="ai-composer" ref={input}>
|
||||
<div className="ai-context">
|
||||
{context.page === "account"
|
||||
? "上下文:个人信息页"
|
||||
: `上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`}
|
||||
</div>
|
||||
<TextArea
|
||||
aria-label="发送给研究助手"
|
||||
placeholder={
|
||||
conversationId
|
||||
? "询问当前 Alpha,或描述要执行的操作…"
|
||||
: "请先新建会话"
|
||||
}
|
||||
value={text}
|
||||
onChange={setText}
|
||||
rows={3}
|
||||
maxCount={20000}
|
||||
disabled={!ready || !conversationId || loading}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="ai-send">
|
||||
<small>Enter 发送 · Shift + Enter 换行</small>
|
||||
{activeRun ? (
|
||||
<Button onClick={() => void stop()}>停止生成</Button>
|
||||
) : (
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={
|
||||
!ready ||
|
||||
!text.trim() ||
|
||||
!conversationId ||
|
||||
loading ||
|
||||
streaming
|
||||
}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</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">
|
||||
<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.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>{researchValue(key, target.before[key])}</del>
|
||||
<ins>{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("、")
|
||||
: "全部 Alpha"}
|
||||
</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="inline-actions">
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={disabled}
|
||||
onClick={() => void onDecision(call.id, true)}
|
||||
>
|
||||
确认执行
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={() => void onDecision(call.id, false)}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</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,201 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { api, post } from "../api";
|
||||
import type { ModelSettings } from "./types";
|
||||
|
||||
export function ModelSettingsPanel() {
|
||||
const [saved, setSaved] = useState<ModelSettings | null>(null);
|
||||
const [draft, setDraft] = useState({
|
||||
base_url: "",
|
||||
model: "",
|
||||
protocol: "chat_completions",
|
||||
enabled: false,
|
||||
});
|
||||
const [key, setKey] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
function apply(value: ModelSettings) {
|
||||
setSaved(value);
|
||||
setDraft({
|
||||
base_url: value.base_url,
|
||||
model: value.model,
|
||||
protocol: value.protocol,
|
||||
enabled: value.enabled,
|
||||
});
|
||||
window.dispatchEvent(new Event("ai-settings-changed"));
|
||||
}
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api<ModelSettings>("/ai/settings")
|
||||
.then((value) => {
|
||||
if (active) apply(value);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
const changed =
|
||||
!!key ||
|
||||
!saved ||
|
||||
["base_url", "model", "protocol"].some(
|
||||
(k) => draft[k as keyof typeof draft] !== saved[k as keyof ModelSettings],
|
||||
);
|
||||
async function save() {
|
||||
setBusy("save");
|
||||
setError("");
|
||||
try {
|
||||
const value = await api<ModelSettings>("/ai/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ...draft, ...(key ? { api_key: key } : {}) }),
|
||||
});
|
||||
apply(value);
|
||||
setKey("");
|
||||
Toast.success("模型配置已保存");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
async function test() {
|
||||
setBusy("test");
|
||||
setError("");
|
||||
try {
|
||||
apply(await post<ModelSettings>("/ai/settings/test"));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="panel full-width ai-settings">
|
||||
<div className="panel-title">
|
||||
<div>
|
||||
<h2>大模型服务</h2>
|
||||
<p>配置你的 OpenAI 兼容服务,供研究助手使用。</p>
|
||||
</div>
|
||||
<Tag color={saved?.enabled && saved.ready ? "green" : "grey"}>
|
||||
{saved?.enabled && saved.ready ? "已启用" : "未启用"}
|
||||
</Tag>
|
||||
</div>
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<div className="ai-settings-grid">
|
||||
<label>
|
||||
Base URL
|
||||
<Input
|
||||
aria-label="模型 Base URL"
|
||||
value={draft.base_url}
|
||||
onChange={(base_url) => setDraft({ ...draft, base_url })}
|
||||
placeholder="https://example.com/v1"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
模型标识
|
||||
<Input
|
||||
aria-label="模型标识"
|
||||
value={draft.model}
|
||||
onChange={(model) => setDraft({ ...draft, model })}
|
||||
placeholder="供应商提供的模型名称"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<Input
|
||||
aria-label="模型 API Key"
|
||||
mode="password"
|
||||
autoComplete="new-password"
|
||||
value={key}
|
||||
onChange={setKey}
|
||||
placeholder={
|
||||
saved?.configured
|
||||
? "已保存;留空保留,更换地址需重填"
|
||||
: "输入服务密钥"
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
接口协议
|
||||
<Select
|
||||
aria-label="模型接口协议"
|
||||
value={draft.protocol}
|
||||
onChange={(protocol) =>
|
||||
setDraft({ ...draft, protocol: String(protocol) })
|
||||
}
|
||||
optionList={[
|
||||
{ value: "chat_completions", label: "Chat Completions" },
|
||||
{ value: "responses", label: "Responses" },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted">
|
||||
填写服务端可访问的 API 根地址,按供应商要求包含 /v1;无需填写
|
||||
/chat/completions 或 /responses。密钥加密保存在后端。
|
||||
</p>
|
||||
<Checkbox
|
||||
checked={draft.enabled}
|
||||
disabled={!saved?.ready || changed}
|
||||
onChange={(e) => setDraft({ ...draft, enabled: !!e.target.checked })}
|
||||
>
|
||||
启用研究助手(能力测试通过后可开启)
|
||||
</Checkbox>
|
||||
<div className="inline-actions ai-settings-actions">
|
||||
<Button
|
||||
htmlType="submit"
|
||||
theme="solid"
|
||||
loading={busy === "save"}
|
||||
disabled={!!busy || !draft.base_url || !draft.model}
|
||||
>
|
||||
保存模型配置
|
||||
</Button>
|
||||
<Button
|
||||
loading={busy === "test"}
|
||||
disabled={!!busy || changed || !saved?.configured}
|
||||
onClick={() => void test()}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
测试连接会向已保存的服务发送少量合成请求,按供应商规则计费。
|
||||
</p>
|
||||
<div className="ai-test-results">
|
||||
{Object.entries(saved?.test_results ?? {}).map(([name, result]) => (
|
||||
<div key={name}>
|
||||
<Tag color={result.ok ? "green" : "red"}>
|
||||
{
|
||||
(
|
||||
{
|
||||
answer: "回答",
|
||||
stream: "流式输出",
|
||||
tools: "工具调用",
|
||||
} as Record<string, string>
|
||||
)[name]
|
||||
}
|
||||
</Tag>
|
||||
<span>{result.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.workspace {
|
||||
padding-right: var(--chat-space, 0px);
|
||||
}
|
||||
.ai-chat {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(var(--chat-width, 420px), 100vw);
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--line);
|
||||
box-shadow: -8px 0 32px #151b2c0b;
|
||||
color: var(--ink);
|
||||
}
|
||||
.ai-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 22px 20px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.ai-header strong {
|
||||
font-size: 17px;
|
||||
}
|
||||
.ai-header span {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.ai-conversations {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.ai-conversations .semi-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ai-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 12px 18px 24px;
|
||||
}
|
||||
.ai-message {
|
||||
margin: 0 0 24px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ai-role {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ai-text {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.ai-message.user .ai-text {
|
||||
background: var(--semi-color-primary-light-default);
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ai-empty {
|
||||
margin: 48px 8px;
|
||||
color: var(--muted);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.ai-empty strong {
|
||||
display: block;
|
||||
color: var(--ink);
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ai-empty span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.ai-composer {
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
.ai-context {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ai-send {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.ai-send small,
|
||||
.ai-tool-card small,
|
||||
.ai-run-state small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-run-state small,
|
||||
.ai-tool-card small {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.ai-tool-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ai-tool-card p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.ai-tool-card summary {
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.ai-diff {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
.ai-diff del,
|
||||
.ai-diff ins {
|
||||
display: block;
|
||||
padding: 8px;
|
||||
white-space: pre-wrap;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ai-diff del {
|
||||
background: #f5636310;
|
||||
text-decoration: none;
|
||||
border-left: 2px solid #c35757;
|
||||
}
|
||||
.ai-diff ins {
|
||||
background: #32925c10;
|
||||
text-decoration: none;
|
||||
border-left: 2px solid #32925c;
|
||||
}
|
||||
.ai-alpha-results > div {
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.ai-alpha-results table {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin-top: 10px;
|
||||
border-collapse: collapse;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ai-alpha-results th {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.ai-resize {
|
||||
position: absolute;
|
||||
left: -4px;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 8px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
.ai-resize:hover,
|
||||
.ai-resize:focus-visible {
|
||||
background: var(--accent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
.ai-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 24px;
|
||||
}
|
||||
.ai-settings .inline-actions {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.ai-settings .panel-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.text-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
padding: 0 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (max-width: 1439px) {
|
||||
.ai-chat {
|
||||
width: min(var(--chat-width, 420px), 100vw);
|
||||
box-shadow: -30px 0 120px #151b2c30;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.ai-chat {
|
||||
width: 100vw;
|
||||
}
|
||||
.ai-resize {
|
||||
display: none;
|
||||
}
|
||||
.ai-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-launcher {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 24px;
|
||||
z-index: 1002;
|
||||
box-shadow: 0 4px 18px #29213e24;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import type { ChatMessage, PageContext } from "./types";
|
||||
|
||||
/** Send only new input. Provider history and tool results are owned by the server. */
|
||||
export function chatTransport(
|
||||
current: () => { conversationId: string; context: PageContext },
|
||||
) {
|
||||
return new DefaultChatTransport<ChatMessage>({
|
||||
credentials: "same-origin",
|
||||
headers: { "X-WQ-Request": "1" },
|
||||
fetch: async (url, init) => {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401)
|
||||
window.dispatchEvent(new Event("session-expired"));
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
typeof body.detail === "string"
|
||||
? body.detail
|
||||
: `请求失败(${response.status})`,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
prepareSendMessagesRequest: ({ messages, body }) => {
|
||||
const state = current();
|
||||
const decision = body?.decision as
|
||||
| { id: string; approved: boolean }
|
||||
| undefined;
|
||||
if (decision)
|
||||
return {
|
||||
api: `/api/v1/ai/approvals/${encodeURIComponent(decision.id)}/decision`,
|
||||
body: { approved: decision.approved },
|
||||
};
|
||||
const last = messages.filter((message) => message.role === "user").at(-1);
|
||||
return {
|
||||
api: `/api/v1/ai/conversations/${encodeURIComponent(state.conversationId)}/runs`,
|
||||
body: {
|
||||
request_id: last?.id,
|
||||
message: last?.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n"),
|
||||
context: state.context,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Research } from "../types";
|
||||
|
||||
export type ModelSettings = {
|
||||
base_url: string;
|
||||
model: string;
|
||||
protocol: "chat_completions" | "responses";
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
ready: boolean;
|
||||
test_results: Record<string, { ok: boolean; message: string }>;
|
||||
};
|
||||
export type PageContext = {
|
||||
page: "alphas" | "account";
|
||||
alpha_id?: string | null;
|
||||
selected_ids?: string[];
|
||||
filters?: Record<string, unknown>;
|
||||
};
|
||||
export type UIAction =
|
||||
| { type: "open_alpha"; alpha_id: string; nonce: number }
|
||||
| { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
|
||||
export type ToolCard = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
preview: {
|
||||
targets?: { alpha_id: string; before: Research; after: Research }[];
|
||||
job?: Record<string, unknown>;
|
||||
operation?: Record<string, unknown>;
|
||||
};
|
||||
result: Record<string, unknown> | null;
|
||||
};
|
||||
export type RunSnapshot = {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
status: string;
|
||||
error?: string | null;
|
||||
model: string;
|
||||
elapsed_ms: number;
|
||||
usage: {
|
||||
token_usage_known?: boolean;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
requests?: number;
|
||||
};
|
||||
tools: ToolCard[];
|
||||
};
|
||||
export type ChatMessage = UIMessage<
|
||||
unknown,
|
||||
{ tool: ToolCard; run: RunSnapshot }
|
||||
>;
|
||||
export type Conversation = { id: string; title: string };
|
||||
export type ConversationDetail = Conversation & {
|
||||
messages: ChatMessage[];
|
||||
runs: RunSnapshot[];
|
||||
};
|
||||
|
||||
export const runLabels: Record<string, string> = {
|
||||
running: "执行中",
|
||||
waiting_approval: "等待确认",
|
||||
completed: "已完成",
|
||||
failed: "执行失败",
|
||||
cancelled: "已停止",
|
||||
interrupted: "执行中断",
|
||||
};
|
||||
export const toolLabels: Record<string, string> = {
|
||||
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: "重试任务",
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
displayValue,
|
||||
formatTime,
|
||||
patch,
|
||||
@@ -39,6 +40,8 @@ export function AlphaDetail({
|
||||
onClose,
|
||||
onSaved,
|
||||
onTask,
|
||||
suspended = false,
|
||||
chatOffset = 0,
|
||||
}: {
|
||||
id: string | null;
|
||||
version: string;
|
||||
@@ -47,6 +50,8 @@ export function AlphaDetail({
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onTask: () => void;
|
||||
suspended?: boolean;
|
||||
chatOffset?: number;
|
||||
}) {
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [pnl, setPnl] = useState<Pnl | null>(null);
|
||||
@@ -54,10 +59,12 @@ export function AlphaDetail({
|
||||
const [tagText, setTagText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [tab, setTab] = useState("overview");
|
||||
const dirty = useRef(false);
|
||||
useEffect(() => {
|
||||
dirty.current = false;
|
||||
setConflict(false);
|
||||
setDetail(null);
|
||||
setResearch(null);
|
||||
setPnl(null);
|
||||
@@ -75,6 +82,11 @@ export function AlphaDetail({
|
||||
if (!dirty.current) {
|
||||
setResearch(value.research);
|
||||
setTagText(value.research.tags.join(", "));
|
||||
} else if (
|
||||
research &&
|
||||
value.research.version !== research.version
|
||||
) {
|
||||
setConflict(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -120,10 +132,19 @@ export function AlphaDetail({
|
||||
.filter(Boolean),
|
||||
});
|
||||
dirty.current = false;
|
||||
setConflict(false);
|
||||
Toast.success("研究记录已保存");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
Toast.error((e as Error).message);
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
setConflict(true);
|
||||
try {
|
||||
setDetail(await api<Detail>(`/alphas/${id}`));
|
||||
} catch {
|
||||
/* Preserve the draft if refresh fails. */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -132,8 +153,10 @@ export function AlphaDetail({
|
||||
<SideSheet
|
||||
closeOnEsc={!taskPanelOpen}
|
||||
className="alpha-detail"
|
||||
visible={Boolean(id)}
|
||||
width={800}
|
||||
visible={Boolean(id) && !suspended}
|
||||
width={Math.min(800, window.innerWidth - chatOffset)}
|
||||
style={{ right: chatOffset }}
|
||||
maskStyle={{ right: chatOffset }}
|
||||
title={
|
||||
<span>
|
||||
Alpha 详情 <code className="muted">{id}</code>
|
||||
@@ -147,6 +170,30 @@ export function AlphaDetail({
|
||||
<Spin />
|
||||
) : (
|
||||
<>
|
||||
{conflict && (
|
||||
<Banner
|
||||
type="warning"
|
||||
description={
|
||||
<span>
|
||||
研究记录已被其他操作更新,你的草稿已保留。请先复制需要保留的内容,再
|
||||
<button
|
||||
className="text-link"
|
||||
onClick={() => {
|
||||
if (detail) {
|
||||
dirty.current = false;
|
||||
setResearch(detail.research);
|
||||
setTagText(detail.research.tags.join(", "));
|
||||
setConflict(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
载入最新记录
|
||||
</button>
|
||||
。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="detail-title">
|
||||
<div>
|
||||
<h2>{detail.name || detail.id}</h2>
|
||||
|
||||
@@ -17,6 +17,7 @@ export function JobPanel({
|
||||
onClose,
|
||||
onChange,
|
||||
onAccount,
|
||||
chatOffset = 0,
|
||||
}: {
|
||||
visible: boolean;
|
||||
jobs: Job[];
|
||||
@@ -24,6 +25,7 @@ export function JobPanel({
|
||||
onClose: () => void;
|
||||
onChange: () => void;
|
||||
onAccount: () => void;
|
||||
chatOffset?: number;
|
||||
}) {
|
||||
const [errors, setErrors] = useState<
|
||||
Record<string, { alpha_id: string; error: string }[]>
|
||||
@@ -46,7 +48,9 @@ export function JobPanel({
|
||||
className="job-panel"
|
||||
title="同步任务"
|
||||
visible={visible}
|
||||
width={520}
|
||||
width={Math.min(520, window.innerWidth - chatOffset)}
|
||||
style={{ right: chatOffset }}
|
||||
maskStyle={{ right: chatOffset }}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<p className="muted">同步进度保存在服务端,关闭此面板不会中断任务。</p>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./style.css";
|
||||
import "./ai/style.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@douyinfe/semi-icons";
|
||||
import { api, displayValue, formatTime, patch, post } from "../api";
|
||||
import type { Account } from "../types";
|
||||
import { ModelSettingsPanel } from "../ai/ModelSettingsPanel";
|
||||
|
||||
const connectionLabels: Record<string, string> = {
|
||||
connected: "已连接",
|
||||
@@ -137,6 +138,7 @@ export function AccountPage({
|
||||
</div>
|
||||
)}
|
||||
<div className="account-grid">
|
||||
<ModelSettingsPanel />
|
||||
<section className="panel">
|
||||
<div className="panel-title">
|
||||
<IconLink />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "../api";
|
||||
import type { Account, Alpha, AlphaPage as Page, Facets } from "../types";
|
||||
import { AlphaDetail } from "../components/AlphaDetail";
|
||||
import type { PageContext, UIAction } from "../ai/types";
|
||||
|
||||
const metricLabels = {
|
||||
sharpe: "Sharpe",
|
||||
@@ -77,12 +78,24 @@ export function AlphaPage({
|
||||
version,
|
||||
onTask,
|
||||
onAccount,
|
||||
active,
|
||||
overlaySuspended,
|
||||
chatOffset,
|
||||
onContext,
|
||||
action,
|
||||
onOverlay,
|
||||
}: {
|
||||
account: Account | null;
|
||||
taskPanelOpen: boolean;
|
||||
version: string;
|
||||
onTask: () => void;
|
||||
onAccount: () => void;
|
||||
active: boolean;
|
||||
overlaySuspended: boolean;
|
||||
chatOffset: number;
|
||||
onContext: (context: PageContext) => void;
|
||||
action: UIAction | null;
|
||||
onOverlay: () => void;
|
||||
}) {
|
||||
const [data, setData] = useState<Page>({
|
||||
items: [],
|
||||
@@ -124,6 +137,8 @@ export function AlphaPage({
|
||||
const [idText, setIdText] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({});
|
||||
const actionNonce = useRef(0);
|
||||
const [bulk, setBulk] = useState({
|
||||
add_tags: "",
|
||||
remove_tags: "",
|
||||
@@ -147,6 +162,46 @@ export function AlphaPage({
|
||||
[filters, sort, direction, pageSize, page],
|
||||
);
|
||||
const query = queryString(params);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
onContext({
|
||||
page: "alphas",
|
||||
alpha_id: detailId,
|
||||
selected_ids: selected,
|
||||
filters: Object.fromEntries(
|
||||
Object.entries(params).filter(
|
||||
([, value]) => value !== "" && value !== undefined && value !== null,
|
||||
),
|
||||
),
|
||||
});
|
||||
}, [active, detailId, selected, params, onContext]);
|
||||
useEffect(() => {
|
||||
if (!action || actionNonce.current === action.nonce) return;
|
||||
actionNonce.current = action.nonce;
|
||||
if (action.type === "open_alpha") {
|
||||
setDetailId(action.alpha_id);
|
||||
onOverlay();
|
||||
} else {
|
||||
const {
|
||||
sort: nextSort,
|
||||
direction: nextDirection,
|
||||
limit: _limit,
|
||||
offset: _offset,
|
||||
...values
|
||||
} = action.filters;
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.filter(([, v]) => v !== null && v !== undefined)
|
||||
.map(([k, v]) => [k, String(v)]),
|
||||
);
|
||||
setDraft(next);
|
||||
setFilters(next);
|
||||
setPage(1);
|
||||
setSelected([]);
|
||||
if (nextSort) setSort(String(nextSort));
|
||||
if (nextDirection) setDirection(String(nextDirection));
|
||||
}
|
||||
}, [action, onOverlay]);
|
||||
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -210,7 +265,8 @@ export function AlphaPage({
|
||||
.filter(Boolean);
|
||||
try {
|
||||
await patch("/alphas/research/bulk", {
|
||||
alpha_ids: selected,
|
||||
alpha_ids: Object.keys(bulkVersions),
|
||||
versions: bulkVersions,
|
||||
add_tags: split(bulk.add_tags),
|
||||
remove_tags: split(bulk.remove_tags),
|
||||
state: bulk.state || null,
|
||||
@@ -233,7 +289,13 @@ export function AlphaPage({
|
||||
fixed: "left",
|
||||
width: 190,
|
||||
render: (_, row) => (
|
||||
<button className="alpha-link" onClick={() => setDetailId(row!.id)}>
|
||||
<button
|
||||
className="alpha-link"
|
||||
onClick={() => {
|
||||
setDetailId(row!.id);
|
||||
onOverlay();
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{row!.research.favorite && <IconStar className="favorite-icon" />}
|
||||
{row!.name || row!.id}
|
||||
@@ -569,7 +631,20 @@ export function AlphaPage({
|
||||
<div className="inline-actions">
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<Button size="small" onClick={() => setBulkOpen(true)}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setBulkVersions(
|
||||
Object.fromEntries(
|
||||
data.items
|
||||
.filter((row) => selected.includes(row.id))
|
||||
.map((row) => [row.id, row.research.version]),
|
||||
),
|
||||
);
|
||||
setBulkOpen(true);
|
||||
onOverlay();
|
||||
}}
|
||||
>
|
||||
批量编辑
|
||||
</Button>
|
||||
<Button
|
||||
@@ -705,7 +780,7 @@ export function AlphaPage({
|
||||
</p>
|
||||
<Modal
|
||||
title="导入 Alpha ID"
|
||||
visible={importing}
|
||||
visible={importing && !overlaySuspended}
|
||||
onCancel={() => setImporting(false)}
|
||||
confirmLoading={busy === "alpha_refresh"}
|
||||
okText="开始导入"
|
||||
@@ -730,8 +805,8 @@ export function AlphaPage({
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={`批量编辑 ${selected.length} 条研究记录`}
|
||||
visible={bulkOpen}
|
||||
title={`批量编辑 ${Object.keys(bulkVersions).length} 条研究记录`}
|
||||
visible={bulkOpen && !overlaySuspended}
|
||||
onCancel={() => setBulkOpen(false)}
|
||||
confirmLoading={busy === "bulk"}
|
||||
onOk={() => void bulkSave()}
|
||||
@@ -768,6 +843,8 @@ export function AlphaPage({
|
||||
</label>
|
||||
</Modal>
|
||||
<AlphaDetail
|
||||
suspended={overlaySuspended}
|
||||
chatOffset={chatOffset}
|
||||
taskPanelOpen={taskPanelOpen}
|
||||
id={detailId}
|
||||
version={`${version}:${localVersion}`}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type ResearchState = "inbox" | "candidate" | "optimizing" | "archived";
|
||||
export type Research = {
|
||||
version: number;
|
||||
note: string;
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
|
||||
Reference in New Issue
Block a user