|
@@ -0,0 +1,1156 @@
|
|
|
|
|
+import { useState, useRef } from 'react';
|
|
|
|
|
+
|
|
|
|
|
+// useInput - 管理输入组件的状态和逻辑
|
|
|
|
|
+export function useInput(onSendMessage, onLoadingChange) {
|
|
|
|
|
+ const {
|
|
|
|
|
+ onSend,
|
|
|
|
|
+ onUpload,
|
|
|
|
|
+ uploadedImages,
|
|
|
|
|
+ removeImage,
|
|
|
|
|
+ workflowRequirements,
|
|
|
|
|
+ } = InputLogic(onSendMessage, onLoadingChange);
|
|
|
|
|
+ const [message, setMessage] = useState('');
|
|
|
|
|
+ const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
+ const fileInputRef = useRef(null);
|
|
|
|
|
+ const textInputRef = useRef(null);
|
|
|
|
|
+
|
|
|
|
|
+ const handleSend = async () => {
|
|
|
|
|
+ const trimmed = message.trim();
|
|
|
|
|
+ // 允许在有图片时发送(即使没有文字),或者有文字时发�?
|
|
|
|
|
+ if ((!trimmed && uploadedImages.length === 0) || isLoading) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 先清空输入框(在发送前清空,提供更好的用户体验�?
|
|
|
|
|
+ setMessage('');
|
|
|
|
|
+
|
|
|
|
|
+ setIsLoading(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ await onSend?.(trimmed || ''); // 如果没有文字,传递空字符�?
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setIsLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleContainerClick = (e) => {
|
|
|
|
|
+ // 如果点击的是容器本身(不是按钮、输入框或其他交互元素),则聚焦输入�?
|
|
|
|
|
+ if (e.target === e.currentTarget) {
|
|
|
|
|
+ textInputRef.current?.focus();
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 有文字或上传了图片就可以发�?
|
|
|
|
|
+ const isSendDisabled = (!message.trim() && uploadedImages.length === 0) || isLoading;
|
|
|
|
|
+
|
|
|
|
|
+ const handleUploadClick = () => {
|
|
|
|
|
+ fileInputRef.current?.click();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleFileChange = async (e) => {
|
|
|
|
|
+ const files = e.target.files;
|
|
|
|
|
+ if (!files || files.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ for (const file of files) {
|
|
|
|
|
+ if (file.type.startsWith('image/')) {
|
|
|
|
|
+ await onUpload?.(file);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 清空input,允许重复选择同一文件
|
|
|
|
|
+ e.target.value = '';
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleSendKeyDown = (e) => {
|
|
|
|
|
+ if ((e.key === 'Enter' || e.key === ' ') && !isSendDisabled) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ handleSend();
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleTextareaKeyDown = (e) => {
|
|
|
|
|
+ // Enter 发送(不按 Ctrl�?
|
|
|
|
|
+ if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ handleSend();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 其他所有按键都允许默认行为(包括Backspace、Delete等)
|
|
|
|
|
+ // 不要阻止任何其他按键的默认行�?
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleUploadKeyDown = (e) => {
|
|
|
|
|
+ if (e.key === 'Enter' || e.key === ' ') {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ handleUploadClick();
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const getSendButtonClassName = () => {
|
|
|
|
|
+ return `send-btn ${isSendDisabled ? 'disabled' : ''}`;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const getSendButtonOnClick = () => {
|
|
|
|
|
+ return !isSendDisabled ? handleSend : undefined;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const getSendButtonTabIndex = () => {
|
|
|
|
|
+ return isSendDisabled ? -1 : 0;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ message,
|
|
|
|
|
+ setMessage,
|
|
|
|
|
+ isLoading,
|
|
|
|
|
+ fileInputRef,
|
|
|
|
|
+ textInputRef,
|
|
|
|
|
+ handleSend,
|
|
|
|
|
+ handleContainerClick,
|
|
|
|
|
+ handleUploadClick,
|
|
|
|
|
+ handleFileChange,
|
|
|
|
|
+ handleSendKeyDown,
|
|
|
|
|
+ handleTextareaKeyDown,
|
|
|
|
|
+ handleUploadKeyDown,
|
|
|
|
|
+ isSendDisabled,
|
|
|
|
|
+ getSendButtonClassName,
|
|
|
|
|
+ getSendButtonOnClick,
|
|
|
|
|
+ getSendButtonTabIndex,
|
|
|
|
|
+ uploadedImages,
|
|
|
|
|
+ removeImage,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// InputLogic - 处理消息发送到GPT API
|
|
|
|
|
+
|
|
|
|
|
+// 第一阶段提示词:分析需求,返回需要的信息
|
|
|
|
|
+const REQUIREMENTS_PROMPT = `你是一个友好的自动化工作流生成助手。你需要像聊天一样与用户对话�?
|
|
|
|
|
+
|
|
|
|
|
+如果用户想要创建自动化工作流,你需要分析需求并返回需要的信息。请用自然、友好的语言与用户交流,保持对话的连贯性和友好性�?
|
|
|
|
|
+
|
|
|
|
|
+语法分层(重要)�?
|
|
|
|
|
+- 基础语法(控制流/定时):scheduleOnce、schedule、for、while、if
|
|
|
|
|
+- 基础 action(模拟手机操作):press、swipe、scroll
|
|
|
|
|
+- 扩展标签(Func):除基础能力外的所有扩展功能,全部�?src/pages/processing/Func/ 目录下的脚本文件决定;每个脚本文件名就是一个“标签”�?
|
|
|
|
|
+
|
|
|
|
|
+在生成工作流时:
|
|
|
|
|
+- 逻辑结构尽量�?for/while/if + triggers(schedules) 表达
|
|
|
|
|
+- 具体手机操作尽量落到 press/swipe/scroll
|
|
|
|
|
+- 当需求超出基础 action(例如:提取会话/消息记录、文字定位等),请使用对应的 Func 标签(例如:extract-chat-history、string-reg-location、save-chat-history�?
|
|
|
|
|
+
|
|
|
|
|
+(实现层说明:项目内部为兼容与落地,仍存�?locate/click/input/ocr/ai-generate/delay/set 等内置能力;但提示用户与生成语法时,请优先按上述分层来组织。)
|
|
|
|
|
+
|
|
|
|
|
+9. if - 条件判断
|
|
|
|
|
+ - condition: 条件表达式(�?"{count} > 5"�?
|
|
|
|
|
+ - then: 条件为真时执行的操作数组
|
|
|
|
|
+ - else: 条件为假时执行的操作数组(可选)
|
|
|
|
|
+
|
|
|
|
|
+10. for - 循环
|
|
|
|
|
+ - variable: 循环变量�?
|
|
|
|
|
+ - items: 数组(可以是变量 {arrayVariable}�?
|
|
|
|
|
+ - body: 循环体操作数�?
|
|
|
|
|
+
|
|
|
|
|
+11. delay - 延迟
|
|
|
|
|
+ - value: 延迟时间(如 "2s", "1m", "30 minutes"�?
|
|
|
|
|
+
|
|
|
|
|
+12. set - 设置变量
|
|
|
|
|
+ - variable: 变量�?
|
|
|
|
|
+ - value: 变量�?
|
|
|
|
|
+
|
|
|
|
|
+13. scroll/swipe - 滚动/滑动
|
|
|
|
|
+ - value: 方向�?up-down", "down-up", "left-right", "right-left"�?
|
|
|
|
|
+
|
|
|
|
|
+定时任务配置�?
|
|
|
|
|
+- 单次执行: {"datetime": "2026/1/14 01:21"}
|
|
|
|
|
+- 每天执行: {"time": "09:00", "repeat": "daily"}
|
|
|
|
|
+- 每周执行: {"time": "09:00", "weekdays": ["monday", "wednesday", "friday"]}
|
|
|
|
|
+- 间隔执行: {"interval": "30 minutes"}
|
|
|
|
|
+
|
|
|
|
|
+重要规则�?
|
|
|
|
|
+- 变量使用 {variableName} 格式引用
|
|
|
|
|
+- 根据用户描述合理设置delay延迟时间
|
|
|
|
|
+- 如果用户提到循环、条件判断,使用for/if操作
|
|
|
|
|
+- 如果用户提到定时执行,添加triggers配置
|
|
|
|
|
+- 如果用户提到AI生成内容,使用ai-generate操作
|
|
|
|
|
+- 如果用户提到消息记录、聊天记录、历史记录、对话记录,使用extract-messages、save-messages、generate-summary操作
|
|
|
|
|
+- 图片文件名按顺序命名�?1.png"�?2.png"�?
|
|
|
|
|
+- **特别重要**:如果工作流中需要输入文字(使用 input �?ai-generate 操作),且这些文字需要根据场景背景和用户角色来生成,你必须询问用户以下信息:
|
|
|
|
|
+ - 场景的背景:这个工作流在什么场景下使用?(例如:微信聊天、客服回复、评论互动等�?
|
|
|
|
|
+ - 用户的角色:用户在这个场景中扮演什么角色?(例如:刚认识女方的男生、客服人员、产品推广者等�?
|
|
|
|
|
+ - 这些信息对于生成合适的文字内容非常重要,请务必在询问需求时主动询问
|
|
|
|
|
+
|
|
|
|
|
+请分析用户的需求,根据上述操作类型,判断需要哪些图片和文字参考:
|
|
|
|
|
+- 图片(needsImages):用于 locate/click �?image 方法,或 ocr �?by-avatar 方法
|
|
|
|
|
+- 文字参考(needsTexts):用于 locate/click �?text 方法
|
|
|
|
|
+
|
|
|
|
|
+如果用户想要创建自动化工作流�?
|
|
|
|
|
+1. 先友好地确认理解用户的需�?
|
|
|
|
|
+2. 然后返回JSON格式的需求信息(不包含在自然语言回复中,仅作为JSON返回�?
|
|
|
|
|
+
|
|
|
|
|
+需求分析的JSON格式�?
|
|
|
|
|
+{
|
|
|
|
|
+ "needsImages": 2, // 需要多少张图片(用于图像匹配定位)
|
|
|
|
|
+ "needsTexts": 1, // 需要多少个文字参考(用于文字识别定位�?
|
|
|
|
|
+ "imageDescriptions": ["登录按钮", "用户名输入框"], // 每张图片的描�?
|
|
|
|
|
+ "textDescriptions": ["登录按钮文字"] // 每个文字参考的描述
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+返回格式:先自然语言回复,然后单独一行JSON�?
|
|
|
|
|
+
|
|
|
|
|
+**特别提醒**:如果工作流涉及输入文字(如自动回复、自动评论、自动发送消息等),请务必询问用户场景背景和角色信息�?
|
|
|
|
|
+
|
|
|
|
|
+例如(不涉及文字输入的场景)�?
|
|
|
|
|
+我理解了,您想创建一个登录流程的自动化工作流。为了生成准确的工作流配置,我需要您提供以下信息�?
|
|
|
|
|
+
|
|
|
|
|
+📋 需要的信息�?
|
|
|
|
|
+🖼�?需�?2 张图片:登录按钮截图、用户名输入框截�?
|
|
|
|
|
+📝 需�?1 个文字参考:登录按钮上的文字
|
|
|
|
|
+
|
|
|
|
|
+\`\`\`json
|
|
|
|
|
+{
|
|
|
|
|
+ "needsImages": 2,
|
|
|
|
|
+ "needsTexts": 1,
|
|
|
|
|
+ "imageDescriptions": ["登录按钮", "用户名输入框"],
|
|
|
|
|
+ "textDescriptions": ["登录按钮文字"]
|
|
|
|
|
+}
|
|
|
|
|
+\`\`\`
|
|
|
|
|
+
|
|
|
|
|
+例如(涉及文字输入的场景):
|
|
|
|
|
+我理解了,您想创建一个自动聊天回复的自动化工作流。为了生成准确且符合场景的文字内容,我需要了解以下信息:
|
|
|
|
|
+
|
|
|
|
|
+📋 需要的信息�?
|
|
|
|
|
+🖼�?需�?2 张图片:好友头像截图、我的头像截�?
|
|
|
|
|
+📝 需�?1 个文字参考:输入框位�?
|
|
|
|
|
+💬 **场景信息**(重要)�?
|
|
|
|
|
+ - 场景背景:这个聊天是在什么场景下进行的?(例如:微信聊天、QQ群聊、客服对话等�?
|
|
|
|
|
+ - 您的角色:您在这个场景中扮演什么角色?(例如:刚认识女方的男生、客服人员、产品推广者等�?
|
|
|
|
|
+
|
|
|
|
|
+\`\`\`json
|
|
|
|
|
+{
|
|
|
|
|
+ "needsImages": 2,
|
|
|
|
|
+ "needsTexts": 1,
|
|
|
|
|
+ "imageDescriptions": ["好友头像", "我的头像"],
|
|
|
|
|
+ "textDescriptions": ["输入框位�?]
|
|
|
|
|
+}
|
|
|
|
|
+\`\`\`
|
|
|
|
|
+
|
|
|
|
|
+如果用户的话题与工作流无关,请友好地自然回复,不需要返回JSON。如果用户是在询问如何使用、或者其他一般性问题,请用自然语言友好地回答。`;
|
|
|
|
|
+
|
|
|
|
|
+// 第二阶段提示词:生成工作�?
|
|
|
|
|
+const SYSTEM_PROMPT = `你是一个友好的自动化工作流生成助手。用户已经提供了所需的图片和文字参考,现在你需要生成JSON格式的工作流配置文件�?
|
|
|
|
|
+
|
|
|
|
|
+请用自然、友好的语言与用户交流,保持对话的连贯性。在生成工作流时,请先简短地确认一下,然后生成JSON格式的工作流配置�?
|
|
|
|
|
+
|
|
|
|
|
+工作流JSON格式(新版本):
|
|
|
|
|
+{
|
|
|
|
|
+ "version": "1.0",
|
|
|
|
|
+ "name": "工作流名�?,
|
|
|
|
|
+ "triggers": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "schedule",
|
|
|
|
|
+ "schedule": {
|
|
|
|
|
+ "time": "09:00",
|
|
|
|
|
+ "weekdays": ["monday", "wednesday", "friday"]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "variables": {
|
|
|
|
|
+ "topic": "美食"
|
|
|
|
|
+ },
|
|
|
|
|
+ "actions": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "locate",
|
|
|
|
|
+ "method": "image",
|
|
|
|
|
+ "target": "button.png",
|
|
|
|
|
+ "variable": "buttonPos",
|
|
|
|
|
+ "delay": "1s"
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "click",
|
|
|
|
|
+ "method": "position",
|
|
|
|
|
+ "target": "{buttonPos}",
|
|
|
|
|
+ "delay": "2s"
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "input",
|
|
|
|
|
+ "target": "输入�?,
|
|
|
|
|
+ "value": "文字内容",
|
|
|
|
|
+ "delay": "1s"
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "ai-generate",
|
|
|
|
|
+ "prompt": "生成关于{topic}的文�?,
|
|
|
|
|
+ "variable": "article",
|
|
|
|
|
+ "delay": "10s"
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "if",
|
|
|
|
|
+ "condition": "{count} > 5",
|
|
|
|
|
+ "then": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "click",
|
|
|
|
|
+ "method": "text",
|
|
|
|
|
+ "target": "确定"
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "for",
|
|
|
|
|
+ "variable": "item",
|
|
|
|
|
+ "items": ["选项1", "选项2"],
|
|
|
|
|
+ "body": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "click",
|
|
|
|
|
+ "method": "text",
|
|
|
|
|
+ "target": "{item}"
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+核心操作类型�?
|
|
|
|
|
+1. locate - 定位元素
|
|
|
|
|
+ - method: "image"(图像匹配)| "text"(文字识别)| "coordinate"(坐标)
|
|
|
|
|
+ - target: 目标内容
|
|
|
|
|
+ - variable: 保存位置到变�?
|
|
|
|
|
+
|
|
|
|
|
+2. click - 点击
|
|
|
|
|
+ - method: "position" | "image" | "text" | "coordinate"
|
|
|
|
|
+ - target: 目标(可以是变量 {variable}�?
|
|
|
|
|
+
|
|
|
|
|
+3. input - 输入文字
|
|
|
|
|
+ - target: 输入框位置(文字或变量)
|
|
|
|
|
+ - value: 要输入的文本(可以是变量 {variable}�?
|
|
|
|
|
+
|
|
|
|
|
+4. ai-generate - AI生成内容
|
|
|
|
|
+ - prompt: 提示词(支持变量 {variable}�?
|
|
|
|
|
+ - variable: 保存结果到变�?
|
|
|
|
|
+
|
|
|
|
|
+5. ocr - 文字识别
|
|
|
|
|
+ - method: "by-avatar" | "by-position" | "full-screen"
|
|
|
|
|
+ - avatar: 头像图片(by-avatar时)
|
|
|
|
|
+ - variable: 保存识别结果
|
|
|
|
|
+
|
|
|
|
|
+6. extract-messages - 提取消息记录
|
|
|
|
|
+ - avatar1: 第一个参与者的头像/标识图片路径
|
|
|
|
|
+ - avatar2: 第二个参与者的头像/标识图片路径
|
|
|
|
|
+ - variable: 保存消息记录到变量(格式:对�? 消息\n�? 消息�?
|
|
|
|
|
+
|
|
|
|
|
+7. save-messages - 保存消息记录
|
|
|
|
|
+ - variable: 包含消息记录的变量名(保存到 history 文件夹)
|
|
|
|
|
+
|
|
|
|
|
+8. generate-summary - 生成消息总结
|
|
|
|
|
+ - variable: 包含消息记录的变量名
|
|
|
|
|
+ - summaryVariable: 保存总结结果的变量名
|
|
|
|
|
+ - model: AI模型名称(可选)
|
|
|
|
|
+
|
|
|
|
|
+9. if - 条件判断
|
|
|
|
|
+ - condition: 条件表达式(�?"{count} > 5"�?
|
|
|
|
|
+ - then: 条件为真时执行的操作数组
|
|
|
|
|
+ - else: 条件为假时执行的操作数组(可选)
|
|
|
|
|
+
|
|
|
|
|
+10. for - 循环
|
|
|
|
|
+ - variable: 循环变量�?
|
|
|
|
|
+ - items: 数组(可以是变量 {arrayVariable}�?
|
|
|
|
|
+ - body: 循环体操作数�?
|
|
|
|
|
+
|
|
|
|
|
+11. delay - 延迟
|
|
|
|
|
+ - value: 延迟时间(如 "2s", "1m", "30 minutes"�?
|
|
|
|
|
+
|
|
|
|
|
+12. set - 设置变量
|
|
|
|
|
+ - variable: 变量�?
|
|
|
|
|
+ - value: 变量�?
|
|
|
|
|
+
|
|
|
|
|
+13. scroll/swipe - 滚动/滑动
|
|
|
|
|
+ - value: 方向�?up-down", "down-up", "left-right", "right-left"�?
|
|
|
|
|
+
|
|
|
|
|
+定时任务配置�?
|
|
|
|
|
+- 单次执行: {"datetime": "2026/1/14 01:21"}
|
|
|
|
|
+- 每天执行: {"time": "09:00", "repeat": "daily"}
|
|
|
|
|
+- 每周执行: {"time": "09:00", "weekdays": ["monday", "wednesday", "friday"]}
|
|
|
|
|
+- 间隔执行: {"interval": "30 minutes"}
|
|
|
|
|
+
|
|
|
|
|
+重要规则�?
|
|
|
|
|
+- 使用新格式(type字段),但保持向后兼容旧格式
|
|
|
|
|
+- 变量使用 {variableName} 格式引用
|
|
|
|
|
+- 根据用户描述合理设置delay延迟时间
|
|
|
|
|
+- 如果用户提到循环、条件判断,使用for/if操作
|
|
|
|
|
+- 如果用户提到定时执行,添加triggers配置
|
|
|
|
|
+- 如果用户提到AI生成内容,使用ai-generate操作
|
|
|
|
|
+- 如果用户提到消息记录、聊天记录、历史记录、对话记录,使用extract-messages、save-messages、generate-summary操作
|
|
|
|
|
+- 在ai-generate的prompt中可以使用总结变量引用历史消息记录的总结
|
|
|
|
|
+- 图片文件名按顺序命名�?1.png"�?2.png"�?
|
|
|
|
|
+
|
|
|
|
|
+返回格式�?
|
|
|
|
|
+1. 如果用户已经提供了所有需要的图片和文字参考,且明确要求生成工作流�?
|
|
|
|
|
+ - 先用简短的自然语言确认(如"好的,我现在为您生成工作流配�?.."�?
|
|
|
|
|
+ - 然后单独一行返回JSON格式的工作流配置(可以用代码块包裹)
|
|
|
|
|
+
|
|
|
|
|
+2. 如果用户只是在询问问题或者还没准备好生成工作流:
|
|
|
|
|
+ - 用自然语言友好地回答用户的问题
|
|
|
|
|
+ - 不需要返回JSON
|
|
|
|
|
+
|
|
|
|
|
+请保持对话的自然和友好,不要生硬地只返回JSON。`;
|
|
|
|
|
+
|
|
|
|
|
+export function InputLogic(onSendMessage, onLoadingChange) {
|
|
|
|
|
+ // 存储上传的图�?
|
|
|
|
|
+ const [uploadedImages, setUploadedImages] = useState([]);
|
|
|
|
|
+
|
|
|
|
|
+ // img2text API可用性标记(使用useRef避免重复输出警告�?
|
|
|
|
|
+ const img2textApiAvailableRef = useRef(true);
|
|
|
|
|
+
|
|
|
|
|
+ // 调用img2text API获取图片描述
|
|
|
|
|
+ const img2text = async (imageBase64, prompt = '请描述这张图片的内容') => {
|
|
|
|
|
+ // 如果已经知道API不可用,直接返回null,不尝试调用
|
|
|
|
|
+ if (!img2textApiAvailableRef.current) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 确保base64数据是完整的data URL格式
|
|
|
|
|
+ let imageUrl = imageBase64;
|
|
|
|
|
+ if (!imageBase64.startsWith('data:')) {
|
|
|
|
|
+ // 如果没有前缀,假设是PNG格式的base64数据
|
|
|
|
|
+ imageUrl = `data:image/png;base64,${imageBase64}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 使用AbortController设置超时,快速失�?
|
|
|
|
|
+ const controller = new AbortController();
|
|
|
|
|
+ const timeoutId = setTimeout(() => controller.abort(), 1000); // 1秒超�?
|
|
|
|
|
+
|
|
|
|
|
+ const response = await fetch('https://ai-anim.com/api/img2text', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
|
|
+ },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ prompt: prompt,
|
|
|
|
|
+ imageUrl: imageUrl
|
|
|
|
|
+ }),
|
|
|
|
|
+ signal: controller.signal,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ clearTimeout(timeoutId);
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ const errorText = await response.text();
|
|
|
|
|
+ throw new Error(`img2text API error: ${response.status} ${response.statusText} - ${errorText}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+ // 支持多种可能的返回格�?
|
|
|
|
|
+ return data.text || data.description || data.result || data.output_text || data.content || '';
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // img2text API是可选的,失败不影响主要功能
|
|
|
|
|
+ // 标记API不可用,之后直接返回null,不再尝�?
|
|
|
|
|
+ if (img2textApiAvailableRef.current) {
|
|
|
|
|
+ img2textApiAvailableRef.current = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 静默返回null,不输出任何错误信息(因为这是可选功能)
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ // 存储工作流需求信息(需要多少图片和文字�?
|
|
|
|
|
+ const [workflowRequirements, setWorkflowRequirements] = useState(null);
|
|
|
|
|
+ // 存储用户输入的文字参考(用于文字识别�?
|
|
|
|
|
+ const [referenceTexts, setReferenceTexts] = useState([]);
|
|
|
|
|
+ // 存储AI生成的工作流JSON(不显示在聊天框�?
|
|
|
|
|
+ const [workflowJson, setWorkflowJson] = useState(null);
|
|
|
|
|
+
|
|
|
|
|
+ // 删除图片
|
|
|
|
|
+ const removeImage = (index) => {
|
|
|
|
|
+ setUploadedImages(prev => prev.filter((_, i) => i !== index));
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 上传图片并命�?
|
|
|
|
|
+ const onUpload = async (file) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 读取图片为base64
|
|
|
|
|
+ const reader = new FileReader();
|
|
|
|
|
+ const base64 = await new Promise((resolve, reject) => {
|
|
|
|
|
+ reader.onload = () => {
|
|
|
|
|
+ const result = reader.result;
|
|
|
|
|
+ // 提取base64数据部分(去掉data:image/...;base64,前缀�?
|
|
|
|
|
+ const base64Data = result.split(',')[1];
|
|
|
|
|
+ resolve(base64Data);
|
|
|
|
|
+ };
|
|
|
|
|
+ reader.onerror = reject;
|
|
|
|
|
+ reader.readAsDataURL(file);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 生成临时文件名(使用时间戳)
|
|
|
|
|
+ const timestamp = Date.now();
|
|
|
|
|
+ const tempFileName = `temp_${timestamp}.${file.name.split('.').pop()}`;
|
|
|
|
|
+
|
|
|
|
|
+ // 存储图片信息
|
|
|
|
|
+ const imageInfo = {
|
|
|
|
|
+ file: file,
|
|
|
|
|
+ base64: base64,
|
|
|
|
|
+ tempName: tempFileName,
|
|
|
|
|
+ originalName: file.name,
|
|
|
|
|
+ timestamp: timestamp,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ setUploadedImages(prev => [...prev, imageInfo]);
|
|
|
|
|
+ console.log('图片已上�?', imageInfo.originalName);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('上传图片失败:', error);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 根据用户描述给图片命名(使用AI�?
|
|
|
|
|
+ const nameImagesWithAI = async (userPrompt, images) => {
|
|
|
|
|
+ if (!images || images.length === 0) return images;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 构建提示词,让AI根据用户描述给图片命�?
|
|
|
|
|
+ const imagePrompt = `用户上传�?${images.length} 张图片,用于工作流自动化。请根据用户的描述,为每张图片生成一个简洁的描述性文件名(只返回JSON数组,不要其他文字)�?
|
|
|
|
|
+
|
|
|
|
|
+用户描述�?{userPrompt}
|
|
|
|
|
+
|
|
|
|
|
+图片信息�?
|
|
|
|
|
+${images.map((img, index) => `图片${index + 1}: 原始文件�?${img.originalName}`).join('\n')}
|
|
|
|
|
+
|
|
|
|
|
+请返回JSON数组,格式如下:
|
|
|
|
|
+["登录按钮.png", "用户名输入框.png", "密码输入�?png"]
|
|
|
|
|
+
|
|
|
|
|
+要求�?
|
|
|
|
|
+1. 文件名要简洁,能清楚描述图片内�?
|
|
|
|
|
+2. 使用中文或英文都可以
|
|
|
|
|
+3. 必须包含文件扩展名(.png�?
|
|
|
|
|
+4. 文件名要符合工作流中使用的命名规范`;
|
|
|
|
|
+
|
|
|
|
|
+ const response = await fetch('https://ai-anim.com/api/text2textByModel', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ prompt: imagePrompt,
|
|
|
|
|
+ modelName: 'gpt-5-nano-ca'
|
|
|
|
|
+ })
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ throw new Error('AI命名失败');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+ const responseText = data.data?.output_text || data.text || data.content || '';
|
|
|
|
|
+
|
|
|
|
|
+ // 解析AI返回的文件名数组
|
|
|
|
|
+ let imageNames = [];
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 尝试直接解析JSON
|
|
|
|
|
+ imageNames = JSON.parse(responseText.trim());
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // 尝试从代码块中提�?
|
|
|
|
|
+ const match = responseText.match(/\[[\s\S]*?\]/);
|
|
|
|
|
+ if (match) {
|
|
|
|
|
+ imageNames = JSON.parse(match[0]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 如果解析失败,使用默认命�?
|
|
|
|
|
+ imageNames = images.map((img, index) => `${index + 1}.png`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 更新图片信息,添加AI生成的文件名
|
|
|
|
|
+ return images.map((img, index) => ({
|
|
|
|
|
+ ...img,
|
|
|
|
|
+ aiName: imageNames[index] || `${index + 1}.png`
|
|
|
|
|
+ }));
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('AI命名图片失败:', error);
|
|
|
|
|
+ // 如果AI命名失败,使用默认命�?
|
|
|
|
|
+ return images.map((img, index) => ({
|
|
|
|
|
+ ...img,
|
|
|
|
|
+ aiName: `${index + 1}.png`
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const sendToGPT = async (userPrompt, images = []) => {
|
|
|
|
|
+ // 构建图片信息提示(如果图片已经命名)
|
|
|
|
|
+ let imageInfoPrompt = '';
|
|
|
|
|
+ if (images.length > 0) {
|
|
|
|
|
+ imageInfoPrompt = `\n\n用户上传了以下图片,请在生成工作流时使用这些图片文件名:\n${images.map((img, index) => `${index + 1}. ${img.aiName || img.tempName} - ${img.originalName}`).join('\n')}\n\n在生成工作流时,如果操作需要点击图片,请使用上述图片文件名(如 "登录按钮.png")。`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 构建完整的提示词:系统提示词 + 图片信息 + 用户输入
|
|
|
|
|
+ const fullPrompt = `${SYSTEM_PROMPT}${imageInfoPrompt}\n\n用户需求:${userPrompt}`;
|
|
|
|
|
+
|
|
|
|
|
+ const requestBody = {
|
|
|
|
|
+ input: fullPrompt
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ console.log('Sending request to Doubao API:', requestBody);
|
|
|
|
|
+
|
|
|
|
|
+ const response = await fetch('https://ai-anim.com/api/doubaoText2text', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
|
|
+ },
|
|
|
|
|
+ body: JSON.stringify(requestBody),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ console.log('Response status:', response.status, response.statusText);
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ let errorText;
|
|
|
|
|
+ try {
|
|
|
|
|
+ errorText = await response.text();
|
|
|
|
|
+ // 尝试解析�?JSON
|
|
|
|
|
+ try {
|
|
|
|
|
+ const errorJson = JSON.parse(errorText);
|
|
|
|
|
+ console.error('API Error Response (JSON):', errorJson);
|
|
|
|
|
+ // 如果 JSON 中有 error 字段,使用它
|
|
|
|
|
+ if (errorJson.error) {
|
|
|
|
|
+ throw new Error(`服务器错�? ${errorJson.error}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (errorJson.message) {
|
|
|
|
|
+ throw new Error(`服务器错�? ${errorJson.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (parseError) {
|
|
|
|
|
+ // 不是 JSON,使用原始文�?
|
|
|
|
|
+ console.error('API Error Response (Text):', errorText);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (readError) {
|
|
|
|
|
+ errorText = `无法读取错误响应: ${readError.message}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+ console.log('API Response:', data);
|
|
|
|
|
+
|
|
|
|
|
+ return data;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 分析需求,返回需要的信息
|
|
|
|
|
+ const analyzeRequirements = async (userPrompt) => {
|
|
|
|
|
+ const response = await fetch('https://ai-anim.com/api/text2textByModel', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ prompt: `${REQUIREMENTS_PROMPT}\n\n用户需求:${userPrompt}`,
|
|
|
|
|
+ modelName: 'gpt-5-nano-ca'
|
|
|
|
|
+ })
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ throw new Error('分析需求失败');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+ const responseText = data.data?.output_text || data.text || data.content || '';
|
|
|
|
|
+
|
|
|
|
|
+ // 提取自然语言回复(去除JSON部分�?
|
|
|
|
|
+ let naturalLanguageReply = responseText.trim();
|
|
|
|
|
+
|
|
|
|
|
+ // 尝试从回复中提取JSON(可能在代码块中,也可能单独一行)
|
|
|
|
|
+ let requirements = null;
|
|
|
|
|
+
|
|
|
|
|
+ // 方法1: 尝试从代码块中提取JSON
|
|
|
|
|
+ const codeBlockMatch = responseText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
|
|
|
|
+ if (codeBlockMatch) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ requirements = JSON.parse(codeBlockMatch[1]);
|
|
|
|
|
+ // 移除代码块部分,保留自然语言
|
|
|
|
|
+ naturalLanguageReply = responseText.replace(/```(?:json)?\s*\{[\s\S]*?\}\s*```/g, '').trim();
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // 解析失败,继续尝试其他方�?
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 方法2: 尝试匹配JSON对象
|
|
|
|
|
+ if (!requirements) {
|
|
|
|
|
+ const jsonMatch = responseText.match(/\{[\s\S]*"needsImages"[\s\S]*\}/);
|
|
|
|
|
+ if (jsonMatch) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ requirements = JSON.parse(jsonMatch[0]);
|
|
|
|
|
+ // 移除JSON部分,保留自然语言
|
|
|
|
|
+ naturalLanguageReply = responseText.replace(/\{[\s\S]*"needsImages"[\s\S]*\}/, '').trim();
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // 解析失败
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 方法3: 尝试直接解析整个文本
|
|
|
|
|
+ if (!requirements) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ requirements = JSON.parse(responseText.trim());
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // 不是纯JSON
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果找到JSON,返回JSON和自然语言回复
|
|
|
|
|
+ if (requirements && typeof requirements === 'object') {
|
|
|
|
|
+ // 将自然语言回复保存到requirements中,以便后续显示
|
|
|
|
|
+ if (naturalLanguageReply && naturalLanguageReply !== responseText.trim()) {
|
|
|
|
|
+ requirements.naturalLanguageReply = naturalLanguageReply;
|
|
|
|
|
+ }
|
|
|
|
|
+ return requirements;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果没有找到JSON,可能是用户的问题与工作流无关,返回错误信息
|
|
|
|
|
+ return {
|
|
|
|
|
+ error: naturalLanguageReply || '请描述你的工作流需求,例如:生成一个登录流程,先点击登录按钮,然后输入用户名和密码'
|
|
|
|
|
+ };
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 解析用户输入的文字参考(按行分割�?
|
|
|
|
|
+ const parseReferenceTexts = (text) => {
|
|
|
|
|
+ // 如果已经有工作流需求,提取文字参�?
|
|
|
|
|
+ if (workflowRequirements && workflowRequirements.needsTexts > 0) {
|
|
|
|
|
+ // 尝试从文本中提取,可能是用换行、逗号或分号分�?
|
|
|
|
|
+ const lines = text.split(/[\n,�?;]/).map(t => t.trim()).filter(t => t);
|
|
|
|
|
+ return lines.slice(0, workflowRequirements.needsTexts);
|
|
|
|
|
+ }
|
|
|
|
|
+ return [];
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const onSend = async (text) => {
|
|
|
|
|
+ if (!text.trim() && uploadedImages.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 保存上传的图片信息(用于后续使用和显示)
|
|
|
|
|
+ const currentImages = [...uploadedImages];
|
|
|
|
|
+
|
|
|
|
|
+ // 添加用户消息(包含图片信息,包含base64数据以便在聊天界面显示)
|
|
|
|
|
+ const userMessage = {
|
|
|
|
|
+ role: 'user',
|
|
|
|
|
+ content: text,
|
|
|
|
|
+ images: currentImages.map(img => ({
|
|
|
|
|
+ name: img.originalName,
|
|
|
|
|
+ tempName: img.tempName,
|
|
|
|
|
+ base64: img.base64, // 包含base64数据,用于在聊天界面显示
|
|
|
|
|
+ originalName: img.originalName
|
|
|
|
|
+ })),
|
|
|
|
|
+ timestamp: new Date(),
|
|
|
|
|
+ };
|
|
|
|
|
+ onSendMessage?.(userMessage);
|
|
|
|
|
+
|
|
|
|
|
+ // 发送消息后立即清空上传的图片,让输入框的提示方块消�?
|
|
|
|
|
+ setUploadedImages([]);
|
|
|
|
|
+
|
|
|
|
|
+ // 开始加�?
|
|
|
|
|
+ onLoadingChange?.(true);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 第一阶段:如果没有需求信息,先分析需�?
|
|
|
|
|
+ if (!workflowRequirements) {
|
|
|
|
|
+ // 如果有参考图,先调用img2text获取图片描述
|
|
|
|
|
+ let imageDescriptionsPrompt = '';
|
|
|
|
|
+ if (currentImages.length > 0) {
|
|
|
|
|
+ const imageDescriptions = [];
|
|
|
|
|
+ for (const img of currentImages) {
|
|
|
|
|
+ const description = await img2text(img.base64, '请详细描述这张图片的内容,包括图片中的界面元素、按钮、文字等信息');
|
|
|
|
|
+ if (description) {
|
|
|
|
|
+ imageDescriptions.push(description);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (imageDescriptions.length > 0) {
|
|
|
|
|
+ imageDescriptionsPrompt = `\n\n用户提供了以下参考图片及其描述:\n${imageDescriptions.map((desc, index) => `图片${index + 1}�?{desc}`).join('\n')}\n\n请根据这些图片描述,分析用户需要哪些图片和文字参考来生成工作流。`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const requirements = await analyzeRequirements(text + imageDescriptionsPrompt);
|
|
|
|
|
+
|
|
|
|
|
+ if (requirements.error) {
|
|
|
|
|
+ // 如果返回错误信息,直接显示(可能是自然语言回复�?
|
|
|
|
|
+ const errorMessage = {
|
|
|
|
|
+ role: 'assistant',
|
|
|
|
|
+ content: requirements.error,
|
|
|
|
|
+ timestamp: new Date(),
|
|
|
|
|
+ };
|
|
|
|
|
+ onSendMessage?.(errorMessage);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 保存需求信�?
|
|
|
|
|
+ setWorkflowRequirements(requirements);
|
|
|
|
|
+
|
|
|
|
|
+ // 显示需求信息给用户(优先显示AI的自然语言回复,然后显示需求列表)
|
|
|
|
|
+ const needsImages = requirements.needsImages || 0;
|
|
|
|
|
+ const needsTexts = requirements.needsTexts || 0;
|
|
|
|
|
+
|
|
|
|
|
+ let requirementMessage = '';
|
|
|
|
|
+
|
|
|
|
|
+ // 如果有自然语言回复,先显示
|
|
|
|
|
+ if (requirements.naturalLanguageReply) {
|
|
|
|
|
+ requirementMessage = requirements.naturalLanguageReply + '\n\n';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 然后显示需求信�?
|
|
|
|
|
+ if (needsImages > 0 || needsTexts > 0) {
|
|
|
|
|
+ requirementMessage += '📋 根据您的需求,我需要以下信息:\n\n';
|
|
|
|
|
+
|
|
|
|
|
+ if (needsImages > 0) {
|
|
|
|
|
+ requirementMessage += `🖼�?需�?${needsImages} 张图片:\n`;
|
|
|
|
|
+ (requirements.imageDescriptions || []).forEach((desc, index) => {
|
|
|
|
|
+ requirementMessage += `${index + 1}. ${desc}\n`;
|
|
|
|
|
+ });
|
|
|
|
|
+ requirementMessage += '\n';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (needsTexts > 0) {
|
|
|
|
|
+ requirementMessage += `📝 需�?${needsTexts} 个文字参考:\n`;
|
|
|
|
|
+ (requirements.textDescriptions || []).forEach((desc, index) => {
|
|
|
|
|
+ requirementMessage += `${index + 1}. ${desc}\n`;
|
|
|
|
|
+ });
|
|
|
|
|
+ requirementMessage += '\n';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ requirementMessage += '璇锋寜椤哄簭涓婁紶鍥剧墖鍜岃緭鍏ユ枃瀛楀弬鑰冿紝鐒跺悗鍐嶆鍙戦€佹秷鎭€?;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const reqMessage = {
|
|
|
|
|
+ role: 'assistant',
|
|
|
|
|
+ content: requirementMessage || '濂界殑锛屾垜鐞嗚В浜嗘偍鐨勯渶姹傘€?,
|
|
|
|
|
+ timestamp: new Date(),
|
|
|
|
|
+ };
|
|
|
|
|
+ onSendMessage?.(reqMessage);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 第二阶段:验证用户提供的信息是否完整
|
|
|
|
|
+ const needsImages = workflowRequirements.needsImages || 0;
|
|
|
|
|
+ const needsTexts = workflowRequirements.needsTexts || 0;
|
|
|
|
|
+ const providedImages = currentImages.length;
|
|
|
|
|
+ const parsedTexts = parseReferenceTexts(text);
|
|
|
|
|
+ const providedTexts = parsedTexts.length;
|
|
|
|
|
+
|
|
|
|
|
+ if (providedImages < needsImages || providedTexts < needsTexts) {
|
|
|
|
|
+ // 信息不完整,提示用户
|
|
|
|
|
+ let missingMessage = '鉂?淇℃伅涓嶅畬鏁达紝璇锋彁渚涳細\n\n';
|
|
|
|
|
+ if (providedImages < needsImages) {
|
|
|
|
|
+ missingMessage += 馃柤锔?杩橀渶瑕?\ 寮犲浘鐗嘰n;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (providedTexts < needsTexts) {
|
|
|
|
|
+ missingMessage += 馃摑 杩橀渶瑕?\ 涓枃瀛楀弬鑰僜n;
|
|
|
|
|
+ }
|
|
|
|
|
+ missingMessage += '\n璇锋寜椤哄簭涓婁紶鍥剧墖鍜岃緭鍏ユ枃瀛楀弬鑰冿紝鐒跺悗鍐嶆鍙戦€佹秷鎭€?;
|
|
|
|
|
+
|
|
|
|
|
+ const missingMsg = {
|
|
|
|
|
+ role: 'assistant',
|
|
|
|
|
+ content: missingMessage,
|
|
|
|
|
+ timestamp: new Date(),
|
|
|
|
|
+ };
|
|
|
|
|
+ onSendMessage?.(missingMsg);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 第三阶段:信息完整,生成工作�?
|
|
|
|
|
+ let namedImages = [];
|
|
|
|
|
+ if (currentImages.length > 0) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 使用需求描述给图片命名
|
|
|
|
|
+ const imageDescriptions = workflowRequirements.imageDescriptions || [];
|
|
|
|
|
+ namedImages = currentImages.map((img, index) => ({
|
|
|
|
|
+ ...img,
|
|
|
|
|
+ aiName: imageDescriptions[index]
|
|
|
|
|
+ ? `${imageDescriptions[index]}.png`
|
|
|
|
|
+ : `${index + 1}.png`
|
|
|
|
|
+ }));
|
|
|
|
|
+ } catch (nameError) {
|
|
|
|
|
+ console.error('图片命名失败,使用默认命�?', nameError);
|
|
|
|
|
+ namedImages = currentImages.map((img, index) => ({
|
|
|
|
|
+ ...img,
|
|
|
|
|
+ aiName: `${index + 1}.png`
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 构建包含文字参考的提示�?
|
|
|
|
|
+ let textReferencesPrompt = '';
|
|
|
|
|
+ if (parsedTexts.length > 0) {
|
|
|
|
|
+ textReferencesPrompt = `\n\n用户提供的文字参考:\n${parsedTexts.map((t, i) => `${i + 1}. ${t}`).join('\n')}\n\n在生成工作流时,如果操作需要定位文字,请使用上述文字参考。`;
|
|
|
|
|
+ // 保存文字参考到state
|
|
|
|
|
+ setReferenceTexts(parsedTexts);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果有参考图,调用img2text获取图片描述并加入到prompt�?
|
|
|
|
|
+ let imageDescriptionsPrompt = '';
|
|
|
|
|
+ if (currentImages.length > 0) {
|
|
|
|
|
+ const imageDescriptions = [];
|
|
|
|
|
+ for (const img of currentImages) {
|
|
|
|
|
+ const description = await img2text(img.base64, '请详细描述这张图片的内容,包括图片中的界面元素、按钮、文字、布局等信息,这些信息将用于生成自动化工作�?);
|
|
|
|
|
+ if (description) {
|
|
|
|
|
+ imageDescriptions.push(description);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (imageDescriptions.length > 0) {
|
|
|
|
|
+ imageDescriptionsPrompt = `\n\n参考图片描述:\n${imageDescriptions.map((desc, index) => {
|
|
|
|
|
+ const imgName = namedImages[index]?.aiName || `${index + 1}.png`;
|
|
|
|
|
+ return `图片 ${imgName}�?{desc}`;
|
|
|
|
|
+ }).join('\n')}\n\n请根据这些图片描述,在生成工作流时合理使用对应的图片文件名。`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 发送到GPT并获取回复(传递已命名的图片信息和文字参考)
|
|
|
|
|
+ const response = await sendToGPT(text + textReferencesPrompt + imageDescriptionsPrompt, namedImages);
|
|
|
|
|
+
|
|
|
|
|
+ // 根据实际 API 响应结构解析文本
|
|
|
|
|
+ // API 返回: { success: true, data: { output_text: "..." } }
|
|
|
|
|
+ const responseText = response.data?.output_text ||
|
|
|
|
|
+ response.text ||
|
|
|
|
|
+ response.content ||
|
|
|
|
|
+ response.message ||
|
|
|
|
|
+ response.data?.text ||
|
|
|
|
|
+ response.data?.content ||
|
|
|
|
|
+ response.result ||
|
|
|
|
|
+ (typeof response === 'string' ? response : JSON.stringify(response));
|
|
|
|
|
+
|
|
|
|
|
+ // 检测是否是JSON格式的工作流
|
|
|
|
|
+ const detectedWorkflowJson = extractWorkflowJsonFromText(responseText);
|
|
|
|
|
+
|
|
|
|
|
+ if (detectedWorkflowJson) {
|
|
|
|
|
+ // 如果检测到工作流JSON,保存到内存中,不显示在聊天�?
|
|
|
|
|
+ setWorkflowJson(detectedWorkflowJson);
|
|
|
|
|
+
|
|
|
|
|
+ // 准备图片数据(base64和文件名�?
|
|
|
|
|
+ const imagesData = namedImages.map(img => ({
|
|
|
|
|
+ name: img.aiName || img.tempName,
|
|
|
|
|
+ base64: img.base64,
|
|
|
|
|
+ originalName: img.originalName
|
|
|
|
|
+ }));
|
|
|
|
|
+
|
|
|
|
|
+ // 建立图片文件名映射:将JSON中可能使用的简单文件名(如 "1.png")映射到实际保存的文件名
|
|
|
|
|
+ // 这样即使AI使用�?"1.png"�?2.png" 等简单名称,也能正确映射到实际的文件�?
|
|
|
|
|
+ const imageNameMap = {};
|
|
|
|
|
+ namedImages.forEach((img, index) => {
|
|
|
|
|
+ const actualName = img.aiName || img.tempName;
|
|
|
|
|
+ // 映射 "1.png", "2.png" �?
|
|
|
|
|
+ imageNameMap[`${index + 1}.png`] = actualName;
|
|
|
|
|
+ // 也映射原始名称(如果有)
|
|
|
|
|
+ if (img.originalName) {
|
|
|
|
|
+ imageNameMap[img.originalName] = actualName;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 在保存前,更新工作流JSON中的图片文件�?
|
|
|
|
|
+ const updatedWorkflowJson = updateImagePathsInWorkflow(detectedWorkflowJson, imageNameMap);
|
|
|
|
|
+
|
|
|
|
|
+ // 直接保存工作流(不显示JSON内容�?
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (window.electronAPI && window.electronAPI.saveWorkflow) {
|
|
|
|
|
+ const saveResult = await window.electronAPI.saveWorkflow(updatedWorkflowJson, imagesData);
|
|
|
|
|
+ if (saveResult.success) {
|
|
|
|
|
+ // 保存成功,清空所有状态(图片已经在发送时清空�?
|
|
|
|
|
+ setWorkflowRequirements(null);
|
|
|
|
|
+ setReferenceTexts([]);
|
|
|
|
|
+ setWorkflowJson(null);
|
|
|
|
|
+
|
|
|
|
|
+ // 显示成功消息(不包含JSON内容�?
|
|
|
|
|
+ const successMessage = {
|
|
|
|
|
+ role: 'assistant',
|
|
|
|
|
+ content: `�?工作流已成功生成并保存!\n\n文件夹名称:${saveResult.folderName}`,
|
|
|
|
|
+ timestamp: new Date(),
|
|
|
|
|
+ };
|
|
|
|
|
+ onSendMessage?.(successMessage);
|
|
|
|
|
+
|
|
|
|
|
+ // 触发工作流列表刷新事�?
|
|
|
|
|
+ window.dispatchEvent(new CustomEvent('workflow-saved'));
|
|
|
|
|
+ return;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 保存失败,显示错�?
|
|
|
|
|
+ console.error('保存工作流失�?', saveResult.error);
|
|
|
|
|
+ const errorMessage = {
|
|
|
|
|
+ role: 'assistant',
|
|
|
|
|
+ content: `�?保存工作流失败:${saveResult.error}`,
|
|
|
|
|
+ timestamp: new Date(),
|
|
|
|
|
+ error: true,
|
|
|
|
|
+ };
|
|
|
|
|
+ onSendMessage?.(errorMessage);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (saveError) {
|
|
|
|
|
+ console.error('保存工作流时出错:', saveError);
|
|
|
|
|
+ const errorMessage = {
|
|
|
|
|
+ role: 'assistant',
|
|
|
|
|
+ content: `�?保存工作流时出错�?{saveError.message}`,
|
|
|
|
|
+ timestamp: new Date(),
|
|
|
|
|
+ error: true,
|
|
|
|
|
+ };
|
|
|
|
|
+ onSendMessage?.(errorMessage);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果没有检测到工作流JSON,显示原始回�?
|
|
|
|
|
+ const gptMessage = {
|
|
|
|
|
+ role: 'assistant',
|
|
|
|
|
+ content: responseText || '抱歉,我无法理解您的请求�?,
|
|
|
|
|
+ timestamp: new Date(),
|
|
|
|
|
+ };
|
|
|
|
|
+ onSendMessage?.(gptMessage);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // 添加错误消息,显示更详细的错误信�?
|
|
|
|
|
+ let errorContent = '抱歉,发送消息时出现错误�?;
|
|
|
|
|
+
|
|
|
|
|
+ if (error.message) {
|
|
|
|
|
+ if (error.message.includes('服务器错�?)) {
|
|
|
|
|
+ errorContent = error.message;
|
|
|
|
|
+ } else if (error.message.includes('HTTP 500')) {
|
|
|
|
|
+ errorContent = '服务器内部错误,请检�?API 端点是否正确,或联系服务器管理员�?;
|
|
|
|
|
+ } else if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
|
|
|
|
|
+ errorContent = '网络连接失败,请检查网络连接或服务器是否可访问�?;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ errorContent = `错误�?{error.message}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const errorMessage = {
|
|
|
|
|
+ role: 'assistant',
|
|
|
|
|
+ content: errorContent,
|
|
|
|
|
+ timestamp: new Date(),
|
|
|
|
|
+ error: true,
|
|
|
|
|
+ };
|
|
|
|
|
+ onSendMessage?.(errorMessage);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ // 结束加载
|
|
|
|
|
+ onLoadingChange?.(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ onSend,
|
|
|
|
|
+ onUpload,
|
|
|
|
|
+ uploadedImages,
|
|
|
|
|
+ removeImage,
|
|
|
|
|
+ workflowRequirements,
|
|
|
|
|
+ setWorkflowRequirements,
|
|
|
|
|
+ referenceTexts,
|
|
|
|
|
+ setReferenceTexts,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 更新工作流JSON中的图片路径
|
|
|
|
|
+function updateImagePathsInWorkflow(workflowJson, imageNameMap) {
|
|
|
|
|
+ // 深拷贝,避免修改原始对象
|
|
|
|
|
+ const updated = JSON.parse(JSON.stringify(workflowJson));
|
|
|
|
|
+
|
|
|
|
|
+ // 递归更新actions中的图片路径
|
|
|
|
|
+ function updateActions(actions) {
|
|
|
|
|
+ if (!Array.isArray(actions)) return;
|
|
|
|
|
+
|
|
|
|
|
+ for (const action of actions) {
|
|
|
|
|
+ // locate �?click 操作可能使用图片
|
|
|
|
|
+ if ((action.type === 'locate' || action.type === 'click') && action.method === 'image' && action.target) {
|
|
|
|
|
+ const target = action.target;
|
|
|
|
|
+ // 检查是否是映射表中的文件名
|
|
|
|
|
+ if (imageNameMap[target]) {
|
|
|
|
|
+ action.target = imageNameMap[target];
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 尝试匹配 "1.png", "2.png" 等格�?
|
|
|
|
|
+ const match = target.match(/^(\d+)\.png$/i);
|
|
|
|
|
+ if (match) {
|
|
|
|
|
+ const index = parseInt(match[1]) - 1;
|
|
|
|
|
+ if (imageNameMap[`${index + 1}.png`]) {
|
|
|
|
|
+ action.target = imageNameMap[`${index + 1}.png`];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ocr 操作可能使用头像图片
|
|
|
|
|
+ if (action.type === 'ocr' && action.method === 'by-avatar' && action.avatar) {
|
|
|
|
|
+ const avatar = action.avatar;
|
|
|
|
|
+ if (imageNameMap[avatar]) {
|
|
|
|
|
+ action.avatar = imageNameMap[avatar];
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const match = avatar.match(/^(\d+)\.png$/i);
|
|
|
|
|
+ if (match) {
|
|
|
|
|
+ const index = parseInt(match[1]) - 1;
|
|
|
|
|
+ if (imageNameMap[`${index + 1}.png`]) {
|
|
|
|
|
+ action.avatar = imageNameMap[`${index + 1}.png`];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 递归处理嵌套�?actions(if、for、while�?
|
|
|
|
|
+ if (action.then && Array.isArray(action.then)) {
|
|
|
|
|
+ updateActions(action.then);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (action.else && Array.isArray(action.else)) {
|
|
|
|
|
+ updateActions(action.else);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (action.body && Array.isArray(action.body)) {
|
|
|
|
|
+ updateActions(action.body);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (updated.actions && Array.isArray(updated.actions)) {
|
|
|
|
|
+ updateActions(updated.actions);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return updated;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 从文本中提取JSON格式的工作流(支持新旧格式)
|
|
|
|
|
+function extractWorkflowJsonFromText(text) {
|
|
|
|
|
+ if (!text) return null;
|
|
|
|
|
+
|
|
|
|
|
+ // 尝试直接解析整个文本
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(text.trim());
|
|
|
|
|
+ // 新格式:包含 actions 字段
|
|
|
|
|
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.actions)) {
|
|
|
|
|
+ return parsed;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 旧格式:直接是数�?
|
|
|
|
|
+ if (Array.isArray(parsed) && parsed.length > 0) {
|
|
|
|
|
+ return { actions: parsed };
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // 不是纯JSON,继续尝试提�?
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 尝试从代码块中提取JSON
|
|
|
|
|
+ const codeBlockMatch = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
|
|
|
|
+ if (codeBlockMatch) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(codeBlockMatch[1]);
|
|
|
|
|
+ if (parsed && typeof parsed === 'object') {
|
|
|
|
|
+ // 新格式:包含 actions
|
|
|
|
|
+ if (Array.isArray(parsed.actions)) {
|
|
|
|
|
+ return parsed;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 旧格式:直接是数�?
|
|
|
|
|
+ if (Array.isArray(parsed)) {
|
|
|
|
|
+ return { actions: parsed };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // 解析失败
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 尝试查找JSON对象(查找包�?actions"的对象,支持多行�?
|
|
|
|
|
+ const jsonMatch = text.match(/\{[\s\S]*?"actions"[\s\S]*?\}/);
|
|
|
|
|
+ if (jsonMatch) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(jsonMatch[0]);
|
|
|
|
|
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.actions)) {
|
|
|
|
|
+ return parsed;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // 解析失败,尝试更宽松的匹�?
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 尝试匹配整个JSON对象(包括嵌套结构)
|
|
|
|
|
+ const fullJsonMatch = text.match(/\{(?:[^{}]|(?:\{[^{}]*\}))*\}/);
|
|
|
|
|
+ if (fullJsonMatch) {
|
|
|
|
|
+ const parsed = JSON.parse(fullJsonMatch[0]);
|
|
|
|
|
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.actions)) {
|
|
|
|
|
+ return parsed;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e2) {
|
|
|
|
|
+ // 解析失败
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|