TopBar.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. import { useCallback, useEffect, useState, useRef } from "react";
  2. import type { FC } from "react";
  3. import ReactMarkdown from "react-markdown";
  4. import { Modal, Form, Toast } from "@douyinfe/semi-ui";
  5. import JSZip from "jszip";
  6. import { traceApi } from "../../api/traceApi";
  7. import type { Goal } from "../../types/goal";
  8. import type { Message } from "../../types/message";
  9. import { AgentControlPanel } from "../AgentControlPanel/AgentControlPanel";
  10. import styles from "./TopBar.module.css";
  11. interface TopBarProps {
  12. selectedTraceId: string | null;
  13. selectedNode: Goal | Message | null;
  14. title: string;
  15. onTraceSelect: (traceId: string, title?: string) => void;
  16. onTraceCreated?: () => void;
  17. onMessageInserted?: () => void;
  18. }
  19. export const TopBar: FC<TopBarProps> = ({
  20. selectedTraceId,
  21. selectedNode,
  22. title,
  23. onTraceSelect,
  24. onTraceCreated,
  25. onMessageInserted,
  26. }) => {
  27. const [isModalVisible, setIsModalVisible] = useState(false);
  28. const [isInsertModalVisible, setIsInsertModalVisible] = useState(false);
  29. const [isReflectModalVisible, setIsReflectModalVisible] = useState(false);
  30. const [isExperienceModalVisible, setIsExperienceModalVisible] = useState(false);
  31. const [experienceContent, setExperienceContent] = useState("");
  32. const [exampleProjects, setExampleProjects] = useState<Array<{ name: string; path: string; has_prompt: boolean }>>(
  33. [],
  34. );
  35. const [isUploading, setIsUploading] = useState(false);
  36. // 控制中心面板
  37. const [isControlPanelVisible, setIsControlPanelVisible] = useState(false);
  38. const formApiRef = useRef<{
  39. getValues: () => { system_prompt?: string; user_prompt?: string; example_project?: string };
  40. setValue: (field: "system_prompt" | "user_prompt" | "example_project", value: string) => void;
  41. } | null>(null);
  42. const insertFormApiRef = useRef<{ getValues: () => { insert_prompt: string } } | null>(null);
  43. const reflectFormApiRef = useRef<{ getValues: () => { reflect_focus: string } } | null>(null);
  44. const isMessageNode = (node: Goal | Message): node is Message =>
  45. "message_id" in node || "role" in node || "content" in node || "goal_id" in node || "tokens" in node;
  46. const getMessageId = (node: Message) => {
  47. if (typeof node.message_id === "string" && node.message_id) return node.message_id;
  48. if (typeof node.id === "string" && node.id) return node.id;
  49. return null;
  50. };
  51. const loadTraces = useCallback(
  52. async (status?: string) => {
  53. try {
  54. const data = await traceApi.fetchTraces({
  55. status: status || undefined,
  56. limit: 20,
  57. });
  58. // 初始加载时不自动选择,或者如果 selectedTraceId 为空才选择第一个
  59. if (!selectedTraceId && data.traces.length > 0) {
  60. const firstTrace = data.traces[0];
  61. const traceId = firstTrace?.parent_trace_id || firstTrace.trace_id;
  62. onTraceSelect(traceId, firstTrace.task);
  63. } else if (data.traces.length === 0) {
  64. onTraceSelect("", "流程图可视化系统");
  65. }
  66. } catch (error) {
  67. console.error("Failed to load traces:", error);
  68. Toast.error("加载任务列表失败");
  69. }
  70. },
  71. [onTraceSelect, selectedTraceId],
  72. );
  73. useEffect(() => {
  74. loadTraces();
  75. }, [loadTraces]);
  76. const handleNewTask = async () => {
  77. setIsModalVisible(true);
  78. // 加载 example 项目列表
  79. try {
  80. const data = await traceApi.fetchExamples();
  81. setExampleProjects(data.projects);
  82. } catch (error) {
  83. console.error("Failed to load examples:", error);
  84. }
  85. };
  86. const handleExampleChange = async (value: string) => {
  87. if (!value) return;
  88. try {
  89. const promptData = await traceApi.fetchExamplePrompt(value);
  90. // 自动填充表单
  91. if (formApiRef.current) {
  92. formApiRef.current.setValue("system_prompt", promptData.system_prompt);
  93. formApiRef.current.setValue("user_prompt", promptData.user_prompt);
  94. }
  95. } catch (error) {
  96. // 若某个 example 没有 production.prompt,只清空即可,不需阻断
  97. if (formApiRef.current) {
  98. formApiRef.current.setValue("system_prompt", "");
  99. formApiRef.current.setValue("user_prompt", "");
  100. }
  101. }
  102. };
  103. const handleConfirm = async () => {
  104. try {
  105. const values = formApiRef.current?.getValues();
  106. if (!values) return;
  107. const messages: Array<{ role: "system" | "user"; content: string }> = [];
  108. if (values.system_prompt) {
  109. messages.push({ role: "system", content: values.system_prompt });
  110. }
  111. if (values.user_prompt) {
  112. messages.push({ role: "user", content: values.user_prompt });
  113. }
  114. const payload: Parameters<typeof traceApi.createTrace>[0] = { messages };
  115. if (values.example_project) {
  116. payload.project_name = values.example_project;
  117. }
  118. const created = await traceApi.createTrace(payload);
  119. const nextTitle =
  120. (typeof values.user_prompt === "string" && values.user_prompt.trim()
  121. ? values.user_prompt.trim().split("\n")[0]
  122. : "新任务") || "新任务";
  123. onTraceSelect(created.trace_id, nextTitle);
  124. await loadTraces();
  125. onTraceCreated?.();
  126. setIsModalVisible(false);
  127. Toast.success("创建成功");
  128. } catch (error) {
  129. console.error("Failed to create trace:", error);
  130. Toast.error("创建失败");
  131. }
  132. };
  133. const handleRun = () => {
  134. if (!selectedNode) {
  135. Toast.warning("请选择插入节点");
  136. return;
  137. }
  138. if (!isMessageNode(selectedNode)) {
  139. Toast.warning("插入位置错误");
  140. return;
  141. }
  142. setIsInsertModalVisible(true);
  143. };
  144. const handleInsertConfirm = async () => {
  145. const node = selectedNode;
  146. if (!node) {
  147. Toast.warning("请选择插入节点");
  148. return;
  149. }
  150. if (!selectedTraceId) {
  151. Toast.warning("请先选择一个 Trace");
  152. return;
  153. }
  154. if (!isMessageNode(node)) {
  155. Toast.warning("插入位置错误");
  156. return;
  157. }
  158. const values = insertFormApiRef.current?.getValues();
  159. const insertPrompt = values?.insert_prompt?.trim();
  160. if (!insertPrompt) {
  161. Toast.warning("请输入指令");
  162. return;
  163. }
  164. const messageId = getMessageId(node);
  165. if (!messageId) {
  166. Toast.error("消息ID缺失");
  167. return;
  168. }
  169. try {
  170. const payload = {
  171. messages: [{ role: "user" as const, content: insertPrompt }],
  172. after_message_id: messageId,
  173. };
  174. await traceApi.runTrace(selectedTraceId, payload);
  175. Toast.success("插入成功");
  176. setIsInsertModalVisible(false);
  177. onMessageInserted?.();
  178. } catch (error) {
  179. console.error("Failed to run trace:", error);
  180. Toast.error("插入失败");
  181. }
  182. };
  183. const handleReflect = async () => {
  184. if (!selectedTraceId) {
  185. Toast.warning("请先选择一个 Trace");
  186. return;
  187. }
  188. setIsReflectModalVisible(true);
  189. };
  190. const handleReflectConfirm = async () => {
  191. if (!selectedTraceId) {
  192. Toast.warning("请先选择一个 Trace");
  193. return;
  194. }
  195. const values = reflectFormApiRef.current?.getValues();
  196. const focus = values?.reflect_focus?.trim();
  197. try {
  198. await traceApi.reflectTrace(selectedTraceId, focus ? { focus } : {});
  199. Toast.success("已触发反思");
  200. setIsReflectModalVisible(false);
  201. } catch (error) {
  202. console.error("Failed to reflect trace:", error);
  203. Toast.error("反思请求失败");
  204. }
  205. };
  206. const handleExperience = async () => {
  207. try {
  208. const content = await traceApi.getExperiences();
  209. // 尝试解析 JSON 格式
  210. let displayContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
  211. try {
  212. const parsed = typeof content === "string" ? JSON.parse(content) : content;
  213. if (parsed && typeof parsed === "object" && "content" in parsed) {
  214. displayContent = parsed.content;
  215. }
  216. } catch (e) {
  217. // 解析失败则使用原始字符串
  218. }
  219. setExperienceContent(displayContent);
  220. setIsExperienceModalVisible(true);
  221. } catch (error) {
  222. console.error("Failed to get experiences:", error);
  223. Toast.error("获取经验失败");
  224. }
  225. };
  226. const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
  227. const files = event.target.files;
  228. if (!files || files.length === 0) return;
  229. setIsUploading(true);
  230. try {
  231. // 创建 ZIP 文件
  232. const zip = new JSZip();
  233. // 将所有文件添加到 ZIP
  234. for (let i = 0; i < files.length; i++) {
  235. const file = files[i];
  236. // 使用 webkitRelativePath 保持文件夹结构
  237. const path = file.webkitRelativePath || file.name;
  238. zip.file(path, file);
  239. }
  240. // 生成 ZIP blob
  241. Toast.info("正在压缩文件...");
  242. const zipBlob = await zip.generateAsync({ type: "blob" });
  243. // 创建 File 对象
  244. const zipFile = new File([zipBlob], "traces.zip", { type: "application/zip" });
  245. // 上传
  246. Toast.info("正在上传...");
  247. const result = await traceApi.uploadTraces(zipFile);
  248. if (result.success) {
  249. Toast.success(result.message);
  250. // 刷新 trace 列表
  251. loadTraces();
  252. if (onTraceCreated) {
  253. onTraceCreated();
  254. }
  255. } else {
  256. Toast.warning(result.message);
  257. }
  258. // 显示详细信息
  259. if (result.failed_traces.length > 0) {
  260. console.warn("Failed traces:", result.failed_traces);
  261. }
  262. } catch (error) {
  263. console.error("Failed to upload traces:", error);
  264. Toast.error("上传失败");
  265. } finally {
  266. setIsUploading(false);
  267. // 清空 input,允许重复上传同一文件
  268. event.target.value = "";
  269. }
  270. };
  271. return (
  272. <>
  273. <header className={styles.topbar}>
  274. <div
  275. className={styles.title}
  276. title={title}
  277. >
  278. <h1>{title}</h1>
  279. </div>
  280. <div className={styles.actions}>
  281. <button
  282. className={`${styles.button} ${styles.success}`}
  283. onClick={handleNewTask}
  284. >
  285. 新任务
  286. </button>
  287. <button
  288. className={`${styles.button} ${styles.primary}`}
  289. onClick={handleRun}
  290. >
  291. 插入
  292. </button>
  293. <button
  294. className={`${styles.button} ${styles.primary}`}
  295. onClick={() => {
  296. if (!selectedTraceId) { Toast.warning("请先选择一个 Trace"); return; }
  297. setIsControlPanelVisible(true);
  298. }}
  299. >
  300. 🎛 控制中心
  301. </button>
  302. <button
  303. className={styles.button}
  304. onClick={handleReflect}
  305. >
  306. 反思
  307. </button>
  308. <button
  309. className={`${styles.button} ${styles.warning}`}
  310. onClick={handleExperience}
  311. >
  312. 经验
  313. </button>
  314. <label className={`${styles.button} ${styles.info}`} style={{ cursor: 'pointer' }}>
  315. {isUploading ? "上传中..." : "📤 导入"}
  316. <input
  317. type="file"
  318. webkitdirectory=""
  319. directory=""
  320. multiple
  321. onChange={handleUpload}
  322. disabled={isUploading}
  323. style={{ display: 'none' }}
  324. />
  325. </label>
  326. </div>
  327. <Modal
  328. title={<div className="w-full text-center">新建任务</div>}
  329. visible={isModalVisible}
  330. onOk={handleConfirm}
  331. onCancel={() => setIsModalVisible(false)}
  332. centered
  333. style={{ width: 600 }}
  334. >
  335. <Form
  336. getFormApi={(api: unknown) => {
  337. formApiRef.current = api as unknown as NonNullable<typeof formApiRef.current>;
  338. }}
  339. >
  340. <Form.Select
  341. field="example_project"
  342. label="选择示例项目(可选)"
  343. placeholder="选择一个示例项目自动填充"
  344. style={{ width: "100%" }}
  345. onChange={handleExampleChange}
  346. showClear
  347. >
  348. {exampleProjects.map((project) => (
  349. <Form.Select.Option
  350. key={project.name}
  351. value={project.name}
  352. >
  353. {project.name}
  354. </Form.Select.Option>
  355. ))}
  356. </Form.Select>
  357. <Form.TextArea
  358. field="system_prompt"
  359. label="System Prompt"
  360. placeholder="请输入 System Prompt"
  361. autosize={{ minRows: 3, maxRows: 6 }}
  362. />
  363. <Form.TextArea
  364. field="user_prompt"
  365. label="User Prompt"
  366. placeholder="请输入 User Prompt"
  367. autosize={{ minRows: 3, maxRows: 6 }}
  368. />
  369. </Form>
  370. </Modal>
  371. <Modal
  372. title={<div className="w-full text-center">插入指令</div>}
  373. visible={isInsertModalVisible}
  374. onOk={handleInsertConfirm}
  375. onCancel={() => setIsInsertModalVisible(false)}
  376. centered
  377. style={{ width: 600 }}
  378. >
  379. <Form
  380. getFormApi={(api: unknown) => {
  381. insertFormApiRef.current = api as unknown as NonNullable<typeof insertFormApiRef.current>;
  382. }}
  383. >
  384. <Form.TextArea
  385. field="insert_prompt"
  386. label=" "
  387. placeholder="请输入插入指令"
  388. autosize={{ minRows: 3, maxRows: 6 }}
  389. />
  390. </Form>
  391. </Modal>
  392. <Modal
  393. title={<div className="w-full text-center">反思</div>}
  394. visible={isReflectModalVisible}
  395. onOk={handleReflectConfirm}
  396. onCancel={() => setIsReflectModalVisible(false)}
  397. centered
  398. style={{ width: 600 }}
  399. >
  400. <Form
  401. getFormApi={(api: unknown) => {
  402. reflectFormApiRef.current = api as unknown as NonNullable<typeof reflectFormApiRef.current>;
  403. }}
  404. >
  405. <Form.TextArea
  406. field="reflect_focus"
  407. label=" "
  408. placeholder="请输入反思重点(可选)"
  409. autosize={{ minRows: 3, maxRows: 6 }}
  410. />
  411. </Form>
  412. </Modal>
  413. <Modal
  414. title={<div className="w-full text-center">经验列表</div>}
  415. visible={isExperienceModalVisible}
  416. onCancel={() => setIsExperienceModalVisible(false)}
  417. footer={null}
  418. centered
  419. style={{ width: 800 }}
  420. bodyStyle={{ maxHeight: "70vh", overflow: "auto" }}
  421. >
  422. <div style={{ whiteSpace: "pre-wrap", wordWrap: "break-word" }}>
  423. {experienceContent ? <ReactMarkdown>{experienceContent}</ReactMarkdown> : "暂无经验数据"}
  424. </div>
  425. </Modal>
  426. </header>
  427. {
  428. isControlPanelVisible && (
  429. <AgentControlPanel
  430. traceId={selectedTraceId}
  431. onClose={() => setIsControlPanelVisible(false)}
  432. onMessageInserted={onMessageInserted}
  433. />
  434. )
  435. }
  436. </>
  437. );
  438. };