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
+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>
);