App.jsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import { useEffect, useMemo, useRef, useState } from "react";
  2. import { buildPlan, streamAgentChat } from "./services/api";
  3. import { renderMermaid } from "./services/mermaid";
  4. const MODES = [
  5. { key: "inspire", label: "灵感模式" },
  6. { key: "standard", label: "标准模式" },
  7. { key: "plan", label: "计划模式" },
  8. { key: "code", label: "Mermaid 代码模式" },
  9. ];
  10. const DIRECTION_OPTIONS = [
  11. { key: "TD", label: "上到下" },
  12. { key: "LR", label: "左到右" },
  13. ];
  14. const MODE_PLACEHOLDER = {
  15. plan: "按行输入流程步骤,例如:\n开始\n数据清洗\n模型训练\n结束",
  16. code: "直接输入 Mermaid 代码",
  17. standard: "请开始输入...",
  18. inspire: "请开始输入...",
  19. };
  20. const CHAT_EMPTY_TEXT = {
  21. standard: "告诉我你的需求,我来帮你优化提示词并生成流程图。",
  22. inspire: "告诉我你的灵感或想法,我来帮你完善并生成流程图。",
  23. };
  24. const ASSISTANT_PREFIX = {
  25. standard: "根据你的需求生成的流程图代码:",
  26. inspire: "根据你的灵感生成的流程图代码:",
  27. };
  28. function downloadText(filename, content) {
  29. const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
  30. const url = URL.createObjectURL(blob);
  31. const a = document.createElement("a");
  32. a.href = url;
  33. a.download = filename;
  34. a.click();
  35. URL.revokeObjectURL(url);
  36. }
  37. export default function App() {
  38. const [mode, setMode] = useState("plan");
  39. const [direction, setDirection] = useState("TD");
  40. const [input, setInput] = useState("");
  41. const [chatInput, setChatInput] = useState("");
  42. const [mermaidCode, setMermaidCode] = useState("flowchart TD\n A[AutoFlow] --> B[就绪]");
  43. const [svg, setSvg] = useState("");
  44. const [error, setError] = useState("");
  45. const [statusText, setStatusText] = useState("等待生成");
  46. const [loading, setLoading] = useState(false);
  47. const [chatMap, setChatMap] = useState({ standard: [], inspire: [] });
  48. const [thinkingMap, setThinkingMap] = useState({ standard: false, inspire: false });
  49. const [zoom, setZoom] = useState(1);
  50. const previewRef = useRef(null);
  51. const dragStateRef = useRef({ dragging: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 });
  52. const isChatMode = mode === "standard" || mode === "inspire";
  53. const currentChat = chatMap[mode] || [];
  54. const isThinking = thinkingMap[mode] || false;
  55. const pushChatMessage = (targetMode, message) => {
  56. setChatMap((prev) => ({
  57. ...prev,
  58. [targetMode]: [...(prev[targetMode] || []), message],
  59. }));
  60. };
  61. const canGenerate = useMemo(() => {
  62. if (isChatMode) return chatInput.trim().length > 0;
  63. if (mode === "code") return mermaidCode.trim().length > 0;
  64. return input.trim().length > 0;
  65. }, [mode, input, mermaidCode, chatInput, isChatMode]);
  66. const applyDirectionToCode = (code, targetDirection) => {
  67. const raw = (code || "").trim();
  68. if (!raw) return "";
  69. const normalized = targetDirection === "LR" ? "LR" : "TD";
  70. const lines = raw.split("\n");
  71. const firstIdx = lines.findIndex((line) => line.trim().length > 0);
  72. if (firstIdx === -1) return raw;
  73. const firstLine = lines[firstIdx];
  74. if (/^(flowchart|graph)\s+(TD|LR|TB|BT|RL)\b/i.test(firstLine.trim())) {
  75. lines[firstIdx] = firstLine.replace(/^(\s*)(flowchart|graph)\s+(TD|LR|TB|BT|RL)\b/i, `$1$2 ${normalized}`);
  76. return lines.join("\n");
  77. }
  78. if (/^(flowchart|graph)\b/i.test(firstLine.trim())) {
  79. lines[firstIdx] = firstLine.replace(/^(\s*)(flowchart|graph)\b/i, `$1$2 ${normalized}`);
  80. return lines.join("\n");
  81. }
  82. return `flowchart ${normalized}\n${raw}`;
  83. };
  84. const previewMermaidCode = useMemo(() => applyDirectionToCode(mermaidCode, direction), [mermaidCode, direction]);
  85. const zoomLabel = `${Math.round(zoom * 100)}%`;
  86. const clampZoom = (value) => Math.min(3, Math.max(0.3, value));
  87. const zoomIn = () => setZoom((prev) => clampZoom(prev + 0.1));
  88. const zoomOut = () => setZoom((prev) => clampZoom(prev - 0.1));
  89. const resetZoom = () => setZoom(1);
  90. const fitToView = () => {
  91. if (!previewRef.current || !svg) return;
  92. const container = previewRef.current;
  93. const svgEl = container.querySelector("svg");
  94. if (!svgEl) return;
  95. const vb = svgEl.viewBox?.baseVal;
  96. const svgWidth = vb && vb.width ? vb.width : svgEl.getBoundingClientRect().width;
  97. const svgHeight = vb && vb.height ? vb.height : svgEl.getBoundingClientRect().height;
  98. if (!svgWidth || !svgHeight) return;
  99. const innerPadding = 32;
  100. const availableWidth = Math.max(120, container.clientWidth - innerPadding);
  101. const availableHeight = Math.max(120, container.clientHeight - innerPadding);
  102. const fitted = clampZoom(Math.min(availableWidth / svgWidth, availableHeight / svgHeight));
  103. setZoom(fitted);
  104. container.scrollLeft = 0;
  105. container.scrollTop = 0;
  106. };
  107. const handlePreviewMouseDown = (e) => {
  108. if (!svg || !previewRef.current) return;
  109. const container = previewRef.current;
  110. dragStateRef.current = {
  111. dragging: true,
  112. startX: e.clientX,
  113. startY: e.clientY,
  114. startLeft: container.scrollLeft,
  115. startTop: container.scrollTop,
  116. };
  117. };
  118. const handlePreviewMouseMove = (e) => {
  119. if (!previewRef.current) return;
  120. const drag = dragStateRef.current;
  121. if (!drag.dragging) return;
  122. const dx = e.clientX - drag.startX;
  123. const dy = e.clientY - drag.startY;
  124. previewRef.current.scrollLeft = drag.startLeft - dx;
  125. previewRef.current.scrollTop = drag.startTop - dy;
  126. };
  127. const stopPreviewDrag = () => {
  128. dragStateRef.current.dragging = false;
  129. };
  130. useEffect(() => {
  131. async function draw() {
  132. const code = (previewMermaidCode || "").trim();
  133. if (!code) {
  134. setSvg("");
  135. setError("");
  136. return;
  137. }
  138. try {
  139. const result = await renderMermaid(code);
  140. setSvg(result.svg);
  141. setError("");
  142. } catch (e) {
  143. setError(`渲染错误: ${e.message}`);
  144. }
  145. }
  146. draw();
  147. }, [previewMermaidCode]);
  148. const runPlanMode = async () => {
  149. const data = await buildPlan(input, "TD");
  150. setMermaidCode(data.mermaid_code || "");
  151. setZoom(1);
  152. setStatusText("计划模式: 已生成流程图,可在预览区切换方向");
  153. };
  154. const runCodeMode = async () => {
  155. setStatusText("代码模式: 使用当前编辑器代码渲染");
  156. };
  157. const runAgentMode = async () => {
  158. const modeKey = mode === "inspire" ? "inspire" : "standard";
  159. const userPrompt = chatInput.trim();
  160. if (!userPrompt) return;
  161. pushChatMessage(modeKey, { role: "user", content: userPrompt, kind: "text" });
  162. setThinkingMap((prev) => ({ ...prev, [modeKey]: true }));
  163. setChatInput("");
  164. let settled = false;
  165. try {
  166. await streamAgentChat(
  167. {
  168. mode: modeKey,
  169. prompt: userPrompt,
  170. direction: "TD",
  171. },
  172. ({ data }) => {
  173. if (data.type === "status") {
  174. setStatusText(`${data.phase}: ${data.message}`);
  175. }
  176. if (data.type === "result") {
  177. settled = true;
  178. setMermaidCode(data.mermaid_code || "");
  179. setZoom(1);
  180. setStatusText(`完成: valid=${data.valid}, attempts=${data.attempts}`);
  181. if (modeKey === "standard" && data.optimized_text) {
  182. pushChatMessage(modeKey, {
  183. role: "assistant",
  184. content: data.optimized_text,
  185. kind: "text",
  186. title: "优化后的提示词:",
  187. });
  188. }
  189. pushChatMessage(modeKey, {
  190. role: "assistant",
  191. content: data.mermaid_code || "",
  192. kind: "code",
  193. title: ASSISTANT_PREFIX[modeKey],
  194. });
  195. }
  196. if (data.type === "error") {
  197. settled = true;
  198. setError(data.message || "智能体执行失败");
  199. }
  200. }
  201. );
  202. if (!settled) {
  203. throw new Error("服务响应中断,请重试");
  204. }
  205. } finally {
  206. setThinkingMap((prev) => ({ ...prev, [modeKey]: false }));
  207. }
  208. };
  209. const handleGenerate = async () => {
  210. setLoading(true);
  211. setError("");
  212. setStatusText("请求处理中...");
  213. try {
  214. if (mode === "plan") {
  215. await runPlanMode();
  216. } else if (mode === "code") {
  217. await runCodeMode();
  218. } else {
  219. await runAgentMode();
  220. }
  221. } catch (e) {
  222. setError(e.message || "请求失败");
  223. } finally {
  224. setLoading(false);
  225. }
  226. };
  227. return (
  228. <div className="page">
  229. <header className="topbar">
  230. <h1>AutoFlow</h1>
  231. <p>计划到流程图,一键生成与实时预览</p>
  232. </header>
  233. <main className="workspace">
  234. <section className="left-panel">
  235. <div className="tabs">
  236. {MODES.map((item) => (
  237. <button
  238. key={item.key}
  239. className={item.key === mode ? "tab active" : "tab"}
  240. onClick={() => setMode(item.key)}
  241. >
  242. {item.label}
  243. </button>
  244. ))}
  245. </div>
  246. {isChatMode ? (
  247. <>
  248. <div className="chat-box">
  249. {currentChat.length === 0 && !isThinking ? (
  250. <div className="chat-empty-wrap">
  251. <div className="chat-empty">{CHAT_EMPTY_TEXT[mode]}</div>
  252. {mode === "inspire" ? <div className="chat-empty-sub">例如:"我想开发一个电商平台"。</div> : null}
  253. </div>
  254. ) : (
  255. currentChat.map((msg, idx) => (
  256. <div key={`${msg.role}-${idx}`} className={`chat-msg ${msg.role}`}>
  257. {msg.role === "assistant" && msg.title ? <div className="chat-title">{msg.title}</div> : null}
  258. {msg.kind === "code" ? (
  259. <pre className="chat-code">{msg.content}</pre>
  260. ) : (
  261. <p>{msg.content}</p>
  262. )}
  263. </div>
  264. ))
  265. )}
  266. {isThinking ? (
  267. <div className="chat-msg assistant thinking">
  268. <div className="thinking-bubble">
  269. 正在思考
  270. <span className="dots">...</span>
  271. </div>
  272. </div>
  273. ) : null}
  274. </div>
  275. <div className="chat-input-row">
  276. <textarea
  277. className="chat-input"
  278. placeholder={MODE_PLACEHOLDER[mode]}
  279. value={chatInput}
  280. onChange={(e) => setChatInput(e.target.value)}
  281. onKeyDown={(e) => {
  282. if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !loading) {
  283. handleGenerate();
  284. }
  285. }}
  286. rows={3}
  287. />
  288. <button className="primary" disabled={!canGenerate || loading} onClick={handleGenerate}>
  289. {loading ? "生成中" : "发送"}
  290. </button>
  291. </div>
  292. </>
  293. ) : (
  294. <>
  295. <textarea
  296. className="editor"
  297. placeholder={MODE_PLACEHOLDER[mode]}
  298. value={mode === "code" ? mermaidCode : input}
  299. onChange={(e) => {
  300. if (mode === "code") {
  301. setMermaidCode(e.target.value);
  302. } else {
  303. setInput(e.target.value);
  304. }
  305. }}
  306. />
  307. <div className="actions">
  308. <button className="primary" disabled={!canGenerate || loading} onClick={handleGenerate}>
  309. {loading ? "生成中..." : "生成/更新"}
  310. </button>
  311. </div>
  312. </>
  313. )}
  314. <div className="log-box">
  315. <strong>当前状态</strong>
  316. {loading && <div className="loader" aria-label="loading" />}
  317. <p>{statusText}</p>
  318. </div>
  319. </section>
  320. <section className="right-panel">
  321. <div className="panel-header">
  322. <h2>实时预览</h2>
  323. <div className="panel-tools">
  324. <div className="preview-controls">
  325. <button className="ghost" onClick={zoomOut} disabled={!svg}>
  326. 缩小
  327. </button>
  328. <span className="zoom-label">{zoomLabel}</span>
  329. <button className="ghost" onClick={zoomIn} disabled={!svg}>
  330. 放大
  331. </button>
  332. <button className="ghost" onClick={resetZoom} disabled={!svg}>
  333. 100%
  334. </button>
  335. <button className="ghost" onClick={fitToView} disabled={!svg}>
  336. 适应窗口
  337. </button>
  338. </div>
  339. <div className="direction-switch">
  340. {DIRECTION_OPTIONS.map((item) => (
  341. <button
  342. key={item.key}
  343. className={item.key === direction ? "dir-btn active" : "dir-btn"}
  344. onClick={() => setDirection(item.key)}
  345. disabled={!svg}
  346. >
  347. {item.label}
  348. </button>
  349. ))}
  350. </div>
  351. <div className="export-actions">
  352. <button className="ghost" onClick={() => downloadText("autoflow.mmd", previewMermaidCode)}>
  353. 导出 .mmd
  354. </button>
  355. <button className="ghost" onClick={() => downloadText("autoflow.svg", svg)} disabled={!svg}>
  356. 导出 SVG
  357. </button>
  358. </div>
  359. </div>
  360. </div>
  361. {error && (mermaidCode || "").trim() ? <div className="error">{error}</div> : null}
  362. <div
  363. className={svg ? "preview" : "preview is-empty"}
  364. ref={previewRef}
  365. onMouseDown={handlePreviewMouseDown}
  366. onMouseMove={handlePreviewMouseMove}
  367. onMouseUp={stopPreviewDrag}
  368. onMouseLeave={stopPreviewDrag}
  369. >
  370. {svg ? (
  371. <div className="preview-scale" style={{ transform: `scale(${zoom})` }}>
  372. <div className="preview-inner" dangerouslySetInnerHTML={{ __html: svg }} />
  373. </div>
  374. ) : (
  375. <div className="preview-empty">暂无图表...</div>
  376. )}
  377. </div>
  378. </section>
  379. </main>
  380. </div>
  381. );
  382. }