feat: 使用 Semi AI 组件升级研究助手与研究入口

This commit is contained in:
yuxuanhui
2026-09-10 12:22:26 +08:00
parent 9334b6f651
commit d403008f9b
16 changed files with 974 additions and 174 deletions
+115
View File
@@ -0,0 +1,115 @@
import { lazy, Suspense } from "react";
import { AIChatDialogue } from "@douyinfe/semi-ui-19";
import { IconAIFilledLevel3, IconUser } from "@douyinfe/semi-icons";
import type { Message } from "@douyinfe/semi-ui-19/lib/es/aiChatDialogue/interface";
import type { Job } from "../types";
import type { ChatMessage, ToolCard, UIAction } from "./types";
import { BusinessCard } from "./ToolCard";
const MessageMarkdown = lazy(() =>
import("./MessageMarkdown").then((module) => ({
default: module.MessageMarkdown,
})),
);
const roleConfig = { user: { name: "你" }, assistant: { name: "研究助手" } };
/** Keep SDK parts and server-owned tool states intact inside Semi's dialogue shell. */
export function AssistantDialogue({
messages,
streaming,
cards,
awaitingApproval,
onDecision,
onAction,
timezone,
jobs,
}: {
messages: ChatMessage[];
streaming: boolean;
cards: Map<string, ToolCard>;
awaitingApproval: boolean;
onDecision: (id: string, approved: boolean) => Promise<void>;
onAction: (action: UIAction) => void;
timezone?: string;
jobs: Job[];
}) {
const sources = new Map(messages.map((message) => [message.id, message]));
const chats: Message[] = messages.map((message) => ({
id: message.id,
role: message.role,
content: message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n"),
status:
streaming &&
message.role === "assistant" &&
message.id === messages.at(-1)?.id
? "in_progress"
: "completed",
}));
return (
<AIChatDialogue
chats={chats}
roleConfig={roleConfig}
align="leftAlign"
mode="userBubble"
showReset={false}
dialogueRenderConfig={{
renderDialogueAvatar: ({ message }) => (
<span className="ai-avatar">
{message?.role === "assistant" ? (
<IconAIFilledLevel3 size="large" aria-hidden="true" />
) : (
<IconUser aria-hidden="true" />
)}
</span>
),
// Only copying is supported; editing/retrying would mutate server-owned history.
renderDialogueAction: ({ message, defaultActionsObj }) =>
message?.content ? defaultActionsObj?.copyNode : null,
renderDialogueContent: ({ message }) => {
const source = message && sources.get(message.id);
if (!source) return null;
return (
<div className={`ai-message ${source.role}`}>
{source.parts.map((part, index) =>
part.type === "text" ? (
source.role === "assistant" ? (
<Suspense
key={index}
fallback={<div className="ai-text">{part.text}</div>}
>
<MessageMarkdown
content={part.text}
final={
!streaming ||
source.id !== messages.at(-1)?.id ||
part.state === "done"
}
/>
</Suspense>
) : (
<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={onDecision}
disabled={streaming || !awaitingApproval}
onAction={onAction}
timezone={timezone}
jobs={jobs}
/>
) : null,
)}
</div>
);
},
}}
/>
);
}
+73 -115
View File
@@ -1,25 +1,16 @@
import { useChat } from "@ai-sdk/react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Banner,
Button,
Select,
Spin,
Tag,
TextArea,
} from "@douyinfe/semi-ui-19";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Banner, Button, Select, Spin, Tag } from "@douyinfe/semi-ui-19";
import { api, post } from "../api";
import type { Job } from "../types";
import { BusinessCard } from "./ToolCard";
import { contextLabel } from "./workspace";
import { AssistantDialogue } from "./AssistantDialogue";
import { ResearchInput } from "./ResearchInput";
import {
IconAIFilledLevel3,
IconPlus,
IconChevronRight,
} from "@douyinfe/semi-icons";
import { contextLabel, contextReferences, researchPrompts } from "./workspace";
import { chatTransport } from "./transport";
import { runLabels } from "./types";
import type {
@@ -33,12 +24,6 @@ import type {
UIAction,
} from "./types";
const MessageMarkdown = lazy(() =>
import("./MessageMarkdown").then((module) => ({
default: module.MessageMarkdown,
})),
);
export function ChatPanel({
open,
context,
@@ -218,8 +203,10 @@ export function ChatPanel({
useEffect(() => {
if (!open) return;
previousFocus.current = document.activeElement as HTMLElement;
const textarea = input.current?.querySelector("textarea");
if (textarea && !textarea.disabled) textarea.focus();
const editor = input.current?.querySelector<HTMLElement>(
'[contenteditable="true"]',
);
if (editor) editor.focus();
else
root.current
?.querySelector<HTMLButtonElement>('button[aria-label="收起研究助手"]')
@@ -257,7 +244,16 @@ export function ChatPanel({
}
}
async function send() {
if (!text.trim() || !conversationId || activeRun || streaming) return;
if (
!ready ||
loading ||
!text.trim() ||
text.length > 20000 ||
!conversationId ||
activeRun ||
streaming
)
return;
const message = text;
setText("");
setFailure("");
@@ -302,6 +298,17 @@ export function ChatPanel({
className="ai-chat"
id="research-assistant"
aria-label="AI 研究助手"
onKeyDownCapture={(event) => {
// Semi consumes Escape in its editor even without a suggestion popup.
if (
event.key === "Escape" &&
(event.target as HTMLElement).closest('[contenteditable="true"]')
) {
event.preventDefault();
event.stopPropagation();
onClose();
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
@@ -310,7 +317,7 @@ export function ChatPanel({
if (event.key === "Tab" && window.innerWidth < 1440) {
const elements = [
...(root.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled), textarea:not(:disabled), input:not(:disabled), [tabindex="0"]',
'button:not(:disabled), [contenteditable="true"], input:not(:disabled), [tabindex="0"]',
) ?? []),
].filter((el) => el.offsetParent !== null);
if (event.shiftKey && document.activeElement === elements[0]) {
@@ -350,10 +357,14 @@ export function ChatPanel({
}}
/>
<header className="ai-header">
<h2>AI 研究助手</h2>
<h2>
<IconAIFilledLevel3 size="large" aria-hidden="true" />
AI 研究助手
</h2>
<Button
type="tertiary"
aria-label="收起研究助手"
icon={<IconChevronRight aria-hidden="true" />}
theme="borderless"
onClick={onClose}
>
@@ -376,7 +387,8 @@ export function ChatPanel({
disabled={loading}
/>
<Button
type="tertiary"
colorful
icon={<IconPlus aria-hidden="true" />}
onClick={() => void createConversation()}
disabled={loading}
>
@@ -408,7 +420,8 @@ export function ChatPanel({
</p>
{!conversationId && (
<Button
type="tertiary"
colorful
icon={<IconAIFilledLevel3 aria-hidden="true" />}
onClick={() => void createConversation()}
disabled={loading}
>
@@ -418,48 +431,16 @@ export function ChatPanel({
<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" ? (
message.role === "assistant" ? (
<Suspense
key={index}
fallback={<div className="ai-text">{part.text}</div>}
>
<MessageMarkdown
content={part.text}
final={
!streaming ||
message.id !== chat.messages.at(-1)?.id ||
part.state === "done"
}
/>
</Suspense>
) : (
<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>
))}
<AssistantDialogue
messages={chat.messages}
streaming={streaming}
cards={cards}
awaitingApproval={activeRun?.status === "waiting_approval"}
onDecision={decide}
onAction={onAction}
timezone={timezone}
jobs={jobs}
/>
{lastRun && (
<div className="ai-run-state">
<Tag color={lastRun.status === "failed" ? "red" : "grey"}>
@@ -483,51 +464,28 @@ export function ChatPanel({
</div>
<footer className="ai-composer" ref={input}>
<div className="ai-context">{contextLabel(context)}</div>
<TextArea
aria-label="发送给研究助手"
placeholder={
conversationId
? "询问当前 Alpha,或描述要执行的操作…"
: "请先新建会话"
}
<ResearchInput
value={text}
onChange={setText}
rows={3}
maxCount={20000}
label="发送给研究助手"
placeholder={
conversationId ? "描述研究问题或要执行的操作…" : "请先新建会话"
}
references={contextReferences(context)}
disabled={!ready || !conversationId || loading}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void send();
}
}}
canSubmit={!activeRun && !streaming}
busy={!!activeRun || streaming}
onSubmit={() => void send()}
onStop={activeRun ? () => void stop() : undefined}
prompts={researchPrompts(context)}
hint={
context.page === "datasets"
? context.unsaved_field_selection
? "字段选择尚未保存;请先保存为固定研究输入。未保存备注不会发送。"
: "引用随当前页面更新,未保存备注不会发送。"
: "引用随当前页面更新。修改和任务操作会先展示预览,确认后执行。"
}
/>
<div className="ai-send">
<small>Enter 发送 · Shift + Enter 换行</small>
{activeRun ? (
<Button type="tertiary" onClick={() => void stop()}>
停止生成
</Button>
) : (
<Button
theme="solid"
disabled={
!ready ||
!text.trim() ||
!conversationId ||
loading ||
streaming
}
onClick={() => void send()}
>
发送
</Button>
)}
</div>
</footer>
</aside>
);
+211
View File
@@ -0,0 +1,211 @@
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { AIChatInput, Button, Tag } from "@douyinfe/semi-ui-19";
import {
IconAIEditLevel3,
IconAIEditLevel1,
IconAIFilledLevel3,
IconStop,
} from "@douyinfe/semi-icons";
export type ResearchReference = { id: string; name: string };
type Editor = ReturnType<AIChatInput["getEditor"]>;
// JSON keeps expressions and user text literal instead of interpreting them as HTML.
function textDocument(value: string) {
return {
type: "doc",
content: value.split("\n").map((text) => ({
type: "paragraph",
...(text ? { content: [{ type: "text", text }] } : {}),
})),
};
}
function editorText(editor?: Editor) {
return editor?.getText({ blockSeparator: "\n" }).replace(/\u200b/g, "") ?? "";
}
/** Plain-text boundary for Semi's editor; references describe existing business context. */
export function ResearchInput({
value,
onChange,
label,
placeholder,
references = [],
disabled = false,
canSubmit = true,
busy = false,
onSubmit,
onStop,
submitLabel = "发送",
hint,
prompts = [],
sendHotKey = "enter",
maxLength = 20000,
}: {
value: string;
onChange: (value: string) => void;
label: string;
placeholder: string;
references?: ResearchReference[];
disabled?: boolean;
canSubmit?: boolean;
busy?: boolean;
onSubmit: () => void;
onStop?: () => void;
submitLabel?: string;
hint?: ReactNode;
prompts?: string[];
sendHotKey?: "enter" | "shift+enter";
maxLength?: number;
}) {
const input = useRef<AIChatInput>(null);
const [editor, setEditor] = useState<Editor>();
const initialContent = useRef(textDocument(value));
const currentPlaceholder = useRef(placeholder);
currentPlaceholder.current = placeholder;
// Tiptap initializes its placeholder extension once; read current availability lazily.
const editorPlaceholder = useCallback(() => currentPlaceholder.current, []);
const tooLong = value.length > maxLength;
const allowed = !disabled && !busy && canSubmit && !!value.trim() && !tooLong;
useEffect(() => {
// Semi exposes its editor after its child mounts, including under StrictMode.
let frame: number;
const connect = () => {
const next = input.current?.getEditor();
if (next && !next.isDestroyed) setEditor(next);
else frame = requestAnimationFrame(connect);
};
frame = requestAnimationFrame(connect);
return () => cancelAnimationFrame(frame);
}, []);
useEffect(() => {
if (!editor || editor.isDestroyed) return;
editor.setOptions({
editorProps: {
...editor.options.editorProps,
attributes: {
role: "textbox",
"aria-label": label,
"aria-multiline": "true",
"aria-disabled": String(disabled),
"aria-invalid": String(tooLong),
},
},
});
editor.setEditable(!disabled, false);
}, [editor, label, disabled, tooLong]);
useEffect(() => {
if (editor && !editor.isDestroyed && editorText(editor) !== value)
editor.commands.setContent(textDocument(value));
}, [editor, value]);
const submit = () => {
if (allowed) onSubmit();
};
return (
<div
className="research-ai-input"
onKeyDownCapture={(event) => {
if (event.key === "Enter" && event.nativeEvent.isComposing)
event.stopPropagation();
}}
>
<AIChatInput
ref={input}
className="research-ai-editor"
defaultContent={initialContent.current}
placeholder={editorPlaceholder}
clearContentOnGenerating={false}
generating={busy}
canSend={allowed}
sendHotKey={sendHotKey}
showUploadButton={false}
showUploadFile={false}
references={references.map((reference) => ({
...reference,
type: "text",
}))}
renderReference={(reference) => (
<span
key={reference.id}
title={reference.name}
className="research-ai-reference"
>
<Tag
colorful
prefixIcon={
<IconAIFilledLevel3 size="small" aria-hidden="true" />
}
>
{reference.name}
</Tag>
</span>
)}
onContentChange={() => onChange(editorText(input.current?.getEditor()))}
onMessageSend={submit}
onStopGenerate={onStop}
renderConfigureArea={() => (
<span className="research-ai-shortcut">
{sendHotKey === "enter"
? "Enter 发送 · Shift + Enter 换行"
: "Shift + Enter 生成 · Enter 换行"}
</span>
)}
renderActionArea={() =>
busy && onStop ? (
<Button
colorful
icon={<IconStop aria-hidden="true" />}
onClick={onStop}
>
停止生成
</Button>
) : (
<Button
colorful
theme="solid"
icon={<IconAIEditLevel1 aria-hidden="true" />}
disabled={!allowed}
loading={busy}
onClick={submit}
>
{submitLabel}
</Button>
)
}
/>
{!value && !busy && prompts.length > 0 && (
<div className="research-ai-prompts" aria-label="研究提示">
{prompts.map((prompt) => (
<Button
key={prompt}
colorful
theme="borderless"
size="small"
icon={<IconAIEditLevel3 aria-hidden="true" />}
disabled={disabled || busy}
onClick={() => {
onChange(prompt);
input.current?.focusEditor("end");
}}
>
{prompt}
</Button>
))}
</div>
)}
{tooLong && (
<p className="error-text" role="alert">
内容不能超过 {maxLength.toLocaleString("en-US")}{" "}
字符,请精简后再发送。
</p>
)}
{hint && <div className="research-ai-hint">{hint}</div>}
</div>
);
}
+7 -1
View File
@@ -1,3 +1,4 @@
import { IconAIFilledLevel3, IconTick } from "@douyinfe/semi-icons";
import type { ComponentType } from "react";
import { Button, Tag } from "@douyinfe/semi-ui-19";
import { ResearchToolCard } from "../research/ResearchToolCard";
@@ -40,7 +41,10 @@ export function BusinessCard(
return (
<section className="ai-tool-card" data-status={call.status}>
<div className="ai-card-title">
<strong>{call.presentation?.label ?? call.name}</strong>
<strong>
<IconAIFilledLevel3 aria-hidden="true" />
{call.presentation?.label ?? call.name}
</strong>
<Tag
color={pending ? "orange" : call.status === "failed" ? "red" : "grey"}
>
@@ -66,6 +70,8 @@ export function BusinessCard(
<div className="inline-actions">
<Button
theme="solid"
colorful
icon={<IconTick aria-hidden="true" />}
disabled={
disabled || !Renderer || call.presentation?.effect !== "confirm"
}
+97 -1
View File
@@ -41,8 +41,12 @@
height: 56px;
padding: 0 var(--space-4);
border-bottom: 1px solid var(--line);
background: var(--semi-color-ai-background-top);
}
.ai-header h2 {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 500;
}
@@ -67,8 +71,8 @@
padding: var(--space-3) var(--space-4) var(--space-4);
}
.ai-message {
margin-bottom: 24px;
overflow-wrap: anywhere;
min-width: 0;
}
.ai-role {
display: block;
@@ -214,9 +218,101 @@
margin-bottom: var(--space-2);
}
.ai-card-title strong {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
}
/* Semi owns the controls; local constraints keep the research rail usable at 360px. */
.research-ai-input {
min-width: 0;
}
.research-ai-editor.semi-aiChatInput {
padding: 12px;
background: var(--semi-color-ai-background-bottom);
border-color: rgba(var(--semi-ai-purple-2), 0.65);
}
.research-ai-editor:focus-within {
border-color: var(--semi-color-ai-purple);
}
.research-ai-editor .tiptap {
min-height: 72px;
max-height: 180px;
overflow-y: auto;
overscroll-behavior: contain;
overflow-wrap: anywhere;
}
.research-ai-editor .semi-aiChatInput-references {
max-height: 100px;
overflow-y: auto;
overscroll-behavior: contain;
}
.research-ai-reference {
max-width: 100%;
min-width: 0;
}
.research-ai-reference .semi-tag {
max-width: 100%;
}
.research-ai-reference .semi-tag-content {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.research-ai-editor .semi-aiChatInput-footer {
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.research-ai-shortcut,
.research-ai-hint {
font-size: 12px;
line-height: 18px;
color: var(--semi-color-text-2);
}
.research-ai-hint {
margin-top: 8px;
}
.research-ai-prompts {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.research-ai-prompts .semi-button {
height: auto;
white-space: normal;
text-align: left;
max-width: 100%;
}
.research-ai-section {
min-width: 0;
}
.research-ai-section h4 {
display: flex;
align-items: center;
gap: 6px;
margin: 12px 0;
font-weight: 500;
}
.ai-avatar {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 8px;
background: var(--semi-color-ai-background-top);
}
.ai-messages .semi-ai-chat-dialogue {
min-height: 0;
}
.ai-messages .semi-ai-chat-dialogue-inner {
min-width: 0;
}
.ai-tool-card p {
margin: var(--space-2) 0;
}
+58
View File
@@ -1,4 +1,62 @@
import type { PageContext, UIAction } from "./types";
import type { ResearchReference } from "./ResearchInput";
/** Only expose context already sent by the transport; never include unsaved notes. */
export function contextReferences(context: PageContext): ResearchReference[] {
const refs: ResearchReference[] = [];
const add = (id: string, label: string, value?: string | null) => {
if (value) refs.push({ id, name: `${label}:${value}` });
};
const scope = context.catalog_scope;
if (scope)
add("scope", "范围", `${scope.region}/${scope.universe}/D${scope.delay}`);
add("dataset", "数据集", context.dataset_id);
add("field", "字段", context.field_id);
add("input", "固定输入", context.template_input_id);
add("version", "集合版本", context.collection_version);
add("asset", "研究素材", context.research_asset_id);
add("experiment", "实验", context.research_experiment_id);
add("alpha", "Alpha", context.alpha_id);
add("run", "研究运行", context.research_run_id);
add("backtest", "回测", context.backtest_run_id);
if (context.selected_ids?.length)
add("selection", "已选 Alpha", `${context.selected_ids.length} 条`);
return refs;
}
export function researchPrompts(context: PageContext): string[] {
switch (context.page) {
case "datasets":
return context.unsaved_field_selection
? ["解释当前数据集的字段和适用场景"]
: context.template_input_id
? [
"分析这个固定输入中的字段,提出可验证的研究假设",
"基于这个固定输入构建候选,并预览回测",
]
: [
"解释当前数据集的字段和适用场景",
"如何选择字段并保存为固定研究输入?",
];
case "templates":
return [
"解释当前模板的经济含义和变量组合",
"检查当前研究输入与模板是否匹配",
];
case "variants":
return [
"分析种子 Alpha,提出结构与字段变体思路",
"比较结构变体与市场设置变体的适用场景",
];
case "features":
return ["解释当前特征方案的处理步骤", "根据固定输入提出可验证的特征假设"];
default:
return [
"查询当前研究对象与已有结果",
"介绍从字段选择到候选回测的研究流程",
];
}
}
export type WorkspacePage = PageContext["page"] | "mcp-keys";
const contextLabels: Record<