feat: 使用 Semi AI 组件升级研究助手与研究入口
This commit is contained in:
+73
-115
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user