diff --git a/README.md b/README.md index f53d51b..29d1422 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

Dify on WeChat

本项目为 [chatgpt-on-wechat](https://github.com/zhayujie/chatgpt-on-wechat)下游分支 -额外对接了LLMOps平台 [Dify](https://github.com/langgenius/dify),支持Dify智能助手模型,调用工具和知识库。 +额外对接了LLMOps平台 [Dify](https://github.com/langgenius/dify),支持Dify智能助手模型,调用工具和知识库,支持Dify工作流。 @@ -12,11 +12,16 @@ ![image-2](./docs/images/image2.jpg) +基本的dify workflow api支持 + +![image-3](./docs/images/image4.jpg) + 目前Dify已经测试过的通道如下: - [x] **个人微信** - [x] **企业微信应用** -- [ ] **公众号** 待测试 +- [x] **企业服务公众号** +- [ ] **个人订阅公众号** 待测试 - [ ] **钉钉** 待测试 - [ ] **飞书** 待测试 @@ -65,10 +70,10 @@ python3 app.py # windows环境下该命令通 # 更新日志 - +- 2024/04/08 支持聊天助手类型应用内置的工作流,支持dify基础的对话工作流,dify官网已正式上线工作流模式。可以导入本项目下的[dsl文件](./dsl/chat-workflow.yml)快速创建工作流进行测试。工作流输入变量名称十分灵活,对于**工作流类型**的应用,本项目**约定工作流的输入变量命名为`query`**,**输出变量命名为`text`**。(ps: 感觉工作流类型应用不太适合作为聊天机器人,现在它还没有会话的概念,需要自己管理上下文。但是它可以调用各种工具,通过http请求和外界交互,适合执行业务逻辑复杂的任务;它可以导入导出工作流dsl文件,方便分享移植。也许以后dsl文件+配置文件就可以作为本项目的一个插件。) - 2024/04/04 支持docker部署 - 2024/03/31 支持coze api(内测版) - +- 2024/03/29 支持dify基础的对话工作流,由于dify官网还未上线工作流,需要自行部署测试 [0.6.0-preview-workflow.1](https://github.com/langgenius/dify/releases/tag/0.6.0-preview-workflow.1)。 # Dify on WeChat 交流群 添加我的微信拉你进群 @@ -123,13 +128,14 @@ pip3 install -r requirements-optional.txt # 国内可以在该命令末尾添加 cp config-template.json config.json ``` -然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(**如果复制下方的示例内容,请去掉注释**): +然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(如果复制下方的示例内容,请**去掉注释**, 务必保证正确配置**dify_app_type**): ```bash # dify config.json文件内容示例 -{ "dify_api_base": "https://api.dify.ai/v1", # dify base url +{ + "dify_api_base": "https://api.dify.ai/v1", # dify base url "dify_api_key": "app-xxx", # dify api key - "dify_agent": true, # dify助手类型,如果是基础助手请设置为false,智能助手请设置为true, 当前为true + "dify_app_type": "chatbot", # dify应用类型 chatbot(对应聊天助手)/agent(对应Agent)/workflow(对应工作流),默认为chatbot "dify_convsersation_max_messages": 5, # dify目前不支持设置历史消息长度,暂时使用超过最大消息数清空会话的策略,缺点是没有滑动窗口,会突然丢失历史消息, 当前为5 "channel_type": "wx", # 通道类型,当前为个人微信 "model": "dify", # 模型名称,当前对应dify平台 diff --git a/bot/dify/dify_bot.py b/bot/dify/dify_bot.py index c49bb22..126d3f7 100644 --- a/bot/dify/dify_bot.py +++ b/bot/dify/dify_bot.py @@ -10,7 +10,7 @@ from bridge.context import ContextType, Context from bridge.reply import Reply, ReplyType from common.log import logger from common import const -from config import conf, load_config +from config import conf class DifyBot(Bot): def __init__(self): @@ -28,9 +28,9 @@ class DifyBot(Bot): channel_type = conf().get("channel_type", "wx") user = None if channel_type == "wx": - user = context["msg"].other_user_nickname + user = context["msg"].other_user_nickname if context.get("msg") else "default" elif channel_type in ["wechatcom_app", "wechatmp", "wechatmp_service"]: - user = context["msg"].other_user_id + user = context["msg"].other_user_id if context.get("msg") else "default" else: return Reply(ReplyType.ERROR, f"unsupported channel type: {channel_type}, now dify only support wx, wechatcom_app, wechatmp, wechatmp_service channel") logger.debug(f"[DIFY] dify_user={user}") @@ -66,76 +66,140 @@ class DifyBot(Bot): def _reply(self, query: str, session: DifySession, context: Context): try: session.count_user_message() # 限制一个conversation中消息数,防止conversation过长 - base_url = self._get_api_base_url() - chat_url = f'{base_url}/chat-messages' - headers = self._get_headers() - is_dify_agent = conf().get('dify_agent', True) - response_mode = 'streaming' if is_dify_agent else 'blocking' - payload = self._get_payload(query, session, response_mode) - response = requests.post(chat_url, headers=headers, json=payload, stream=is_dify_agent) - if response.status_code != 200: - error_info = f"[DIFY] response text={response.text} status_code={response.status_code}" - logger.warn(error_info) - return None, error_info - - if is_dify_agent: - # response: - # data: {"event": "agent_thought", "id": "8dcf3648-fbad-407a-85dd-73a6f43aeb9f", "task_id": "9cf1ddd7-f94b-459b-b942-b77b26c59e9b", "message_id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "position": 1, "thought": "", "observation": "", "tool": "", "tool_input": "", "created_at": 1705639511, "message_files": [], "conversation_id": "c216c595-2d89-438c-b33c-aae5ddddd142"} - # data: {"event": "agent_thought", "id": "8dcf3648-fbad-407a-85dd-73a6f43aeb9f", "task_id": "9cf1ddd7-f94b-459b-b942-b77b26c59e9b", "message_id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "position": 1, "thought": "", "observation": "", "tool": "dalle3", "tool_input": "{\"dalle3\": {\"prompt\": \"cute Japanese anime girl with white hair, blue eyes, bunny girl suit\"}}", "created_at": 1705639511, "message_files": [], "conversation_id": "c216c595-2d89-438c-b33c-aae5ddddd142"} - # data: {"event": "agent_message", "id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "task_id": "9cf1ddd7-f94b-459b-b942-b77b26c59e9b", "message_id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "answer": "I have created an image of a cute Japanese", "created_at": 1705639511, "conversation_id": "c216c595-2d89-438c-b33c-aae5ddddd142"} - # data: {"event": "message_end", "task_id": "9cf1ddd7-f94b-459b-b942-b77b26c59e9b", "id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "message_id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "conversation_id": "c216c595-2d89-438c-b33c-aae5ddddd142", "metadata": {"usage": {"prompt_tokens": 305, "prompt_unit_price": "0.001", "prompt_price_unit": "0.001", "prompt_price": "0.0003050", "completion_tokens": 97, "completion_unit_price": "0.002", "completion_price_unit": "0.001", "completion_price": "0.0001940", "total_tokens": 184, "total_price": "0.0002290", "currency": "USD", "latency": 1.771092874929309}}} - msgs, conversation_id = self._handle_sse_response(response) - channel = context.get("channel") - # TODO: 适配除微信以外的其他channel - is_group = context.get("isgroup", False) - for msg in msgs[:-1]: - if msg['type'] == 'agent_message': - if is_group: - at_prefix = "@" + context["msg"].actual_user_nickname + "\n" - msg['content'] = at_prefix + msg['content'] - reply = Reply(ReplyType.TEXT, msg['content']) - channel.send(reply, context) - elif msg['type'] == 'message_file': - reply = Reply(ReplyType.IMAGE_URL, msg['content']['url']) - thread = threading.Thread(target=channel.send, args=(reply, context)) - thread.start() - final_msg = msgs[-1] - reply = None - if final_msg['type'] == 'agent_message': - reply = Reply(ReplyType.TEXT, final_msg['content']) - elif final_msg['type'] == 'message_file': - reply = Reply(ReplyType.IMAGE_URL, final_msg['content']['url']) - # 设置dify conversation_id, 依靠dify管理上下文 - if session.get_conversation_id() == '': - session.set_conversation_id(conversation_id) - return reply, None + dify_app_type = conf().get('dify_app_type', 'chatbot') + if dify_app_type == 'chatbot': + return self._handle_chatbot(query, session) + elif dify_app_type == 'agent': + return self._handle_agent(query, session, context) + elif dify_app_type == 'workflow': + return self._handle_workflow(query, session) else: - # response: - # { - # "event": "message", - # "message_id": "9da23599-e713-473b-982c-4328d4f5c78a", - # "conversation_id": "45701982-8118-4bc5-8e9b-64562b4555f2", - # "mode": "chat", - # "answer": "xxx", - # "metadata": { - # "usage": { - # }, - # "retriever_resources": [] - # }, - # "created_at": 1705407629 - # } - rsp_data = response.json() - logger.debug("[DIFY] usage ".format(rsp_data['metadata']['usage'])) - reply = Reply(ReplyType.TEXT, rsp_data['answer']) - # 设置dify conversation_id, 依靠dify管理上下文 - if session.get_conversation_id() == '': - session.set_conversation_id(rsp_data['conversation_id']) - return reply, None + return None, "dify_app_type must be agent, chatbot or workflow" + except Exception as e: error_info = f"[DIFY] Exception: {e}" logger.exception(error_info) return None, error_info + def _handle_chatbot(self, query: str, session: DifySession): + # TODO: 获取response部分抽取为公共函数 + base_url = self._get_api_base_url() + chat_url = f'{base_url}/chat-messages' + headers = self._get_headers() + response_mode = 'blocking' + payload = self._get_payload(query, session, response_mode) + response = requests.post(chat_url, headers=headers, json=payload) + if response.status_code != 200: + error_info = f"[DIFY] response text={response.text} status_code={response.status_code}" + logger.warn(error_info) + return None, error_info + + # response: + # { + # "event": "message", + # "message_id": "9da23599-e713-473b-982c-4328d4f5c78a", + # "conversation_id": "45701982-8118-4bc5-8e9b-64562b4555f2", + # "mode": "chat", + # "answer": "xxx", + # "metadata": { + # "usage": { + # }, + # "retriever_resources": [] + # }, + # "created_at": 1705407629 + # } + rsp_data = response.json() + logger.debug("[DIFY] usage ".format(rsp_data['metadata']['usage'])) + reply = Reply(ReplyType.TEXT, rsp_data['answer']) + # 设置dify conversation_id, 依靠dify管理上下文 + if session.get_conversation_id() == '': + session.set_conversation_id(rsp_data['conversation_id']) + return reply, None + + def _handle_agent(self, query: str, session: DifySession, context: Context): + # TODO: 获取response抽取为公共函数 + base_url = self._get_api_base_url() + chat_url = f'{base_url}/chat-messages' + headers = self._get_headers() + response_mode = 'streaming' + payload = self._get_payload(query, session, response_mode) + response = requests.post(chat_url, headers=headers, json=payload) + if response.status_code != 200: + error_info = f"[DIFY] response text={response.text} status_code={response.status_code}" + logger.warn(error_info) + return None, error_info + # response: + # data: {"event": "agent_thought", "id": "8dcf3648-fbad-407a-85dd-73a6f43aeb9f", "task_id": "9cf1ddd7-f94b-459b-b942-b77b26c59e9b", "message_id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "position": 1, "thought": "", "observation": "", "tool": "", "tool_input": "", "created_at": 1705639511, "message_files": [], "conversation_id": "c216c595-2d89-438c-b33c-aae5ddddd142"} + # data: {"event": "agent_thought", "id": "8dcf3648-fbad-407a-85dd-73a6f43aeb9f", "task_id": "9cf1ddd7-f94b-459b-b942-b77b26c59e9b", "message_id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "position": 1, "thought": "", "observation": "", "tool": "dalle3", "tool_input": "{\"dalle3\": {\"prompt\": \"cute Japanese anime girl with white hair, blue eyes, bunny girl suit\"}}", "created_at": 1705639511, "message_files": [], "conversation_id": "c216c595-2d89-438c-b33c-aae5ddddd142"} + # data: {"event": "agent_message", "id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "task_id": "9cf1ddd7-f94b-459b-b942-b77b26c59e9b", "message_id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "answer": "I have created an image of a cute Japanese", "created_at": 1705639511, "conversation_id": "c216c595-2d89-438c-b33c-aae5ddddd142"} + # data: {"event": "message_end", "task_id": "9cf1ddd7-f94b-459b-b942-b77b26c59e9b", "id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "message_id": "1fb10045-55fd-4040-99e6-d048d07cbad3", "conversation_id": "c216c595-2d89-438c-b33c-aae5ddddd142", "metadata": {"usage": {"prompt_tokens": 305, "prompt_unit_price": "0.001", "prompt_price_unit": "0.001", "prompt_price": "0.0003050", "completion_tokens": 97, "completion_unit_price": "0.002", "completion_price_unit": "0.001", "completion_price": "0.0001940", "total_tokens": 184, "total_price": "0.0002290", "currency": "USD", "latency": 1.771092874929309}}} + msgs, conversation_id = self._handle_sse_response(response) + channel = context.get("channel") + # TODO: 适配除微信以外的其他channel + is_group = context.get("isgroup", False) + for msg in msgs[:-1]: + if msg['type'] == 'agent_message': + if is_group: + at_prefix = "@" + context["msg"].actual_user_nickname + "\n" + msg['content'] = at_prefix + msg['content'] + reply = Reply(ReplyType.TEXT, msg['content']) + channel.send(reply, context) + elif msg['type'] == 'message_file': + reply = Reply(ReplyType.IMAGE_URL, msg['content']['url']) + thread = threading.Thread(target=channel.send, args=(reply, context)) + thread.start() + final_msg = msgs[-1] + reply = None + if final_msg['type'] == 'agent_message': + reply = Reply(ReplyType.TEXT, final_msg['content']) + elif final_msg['type'] == 'message_file': + reply = Reply(ReplyType.IMAGE_URL, final_msg['content']['url']) + # 设置dify conversation_id, 依靠dify管理上下文 + if session.get_conversation_id() == '': + session.set_conversation_id(conversation_id) + return reply, None + + def _handle_workflow(self, query: str, session: DifySession): + base_url = self._get_api_base_url() + workflow_url = f'{base_url}/workflows/run' + headers = self._get_headers() + payload = self._get_workflow_payload(query, session) + response = requests.post(workflow_url, headers=headers, json=payload) + if response.status_code != 200: + error_info = f"[DIFY] response text={response.text} status_code={response.status_code}" + logger.warn(error_info) + return None, error_info + # { + # "log_id": "djflajgkldjgd", + # "task_id": "9da23599-e713-473b-982c-4328d4f5c78a", + # "data": { + # "id": "fdlsjfjejkghjda", + # "workflow_id": "fldjaslkfjlsda", + # "status": "succeeded", + # "outputs": { + # "text": "Nice to meet you." + # }, + # "error": null, + # "elapsed_time": 0.875, + # "total_tokens": 3562, + # "total_steps": 8, + # "created_at": 1705407629, + # "finished_at": 1727807631 + # } + # } + rsp_data = response.json() + reply = Reply(ReplyType.TEXT, rsp_data['data']['outputs']['text']) + return reply, None + + def _get_workflow_payload(self, query, session: DifySession): + return { + 'inputs': { + "query": query + }, + "response_mode": "blocking", + "user": session.get_user() + } + def _parse_sse_event(self, event_str): """ Parses a single SSE event string and returns a dictionary of its data. diff --git a/config.py b/config.py index 71f1ac5..655a09e 100644 --- a/config.py +++ b/config.py @@ -78,7 +78,7 @@ available_setting = { # dify配置 "dify_api_base": "https://api.dify.ai/v1", "dify_api_key": "app-xxx", - "dify_agent": True, # dify助手类型,如果是基础助手请设置为False,智能助手请设置为True,默认为True + "dify_app_type": "chatbot", # dify助手类型 chatbot(对应聊天助手)/agent(对应Agent)/workflow(对应工作流),默认为chatbot "dify_convsersation_max_messages": 5, # dify目前不支持设置历史消息长度,暂时使用超过最大消息数清空会话的策略,缺点是没有滑动窗口,会突然丢失历史消息 # coze配置 "coze_api_base": "https://api.coze.cn/open_api/v2", diff --git a/docs/images/image4.jpg b/docs/images/image4.jpg new file mode 100644 index 0000000..ba49ae3 Binary files /dev/null and b/docs/images/image4.jpg differ diff --git a/dsl/chat-workflow.yml b/dsl/chat-workflow.yml new file mode 100644 index 0000000..1b668b4 --- /dev/null +++ b/dsl/chat-workflow.yml @@ -0,0 +1,154 @@ +app: + description: '' + icon: "\U0001F916" + icon_background: '#FFEAD5' + mode: workflow + name: chat-workflow +workflow: + features: + file_upload: + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + opening_statement: '' + retriever_resource: + enabled: false + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + sourceType: start + targetType: llm + id: 1712585213444-1712585220528 + source: '1712585213444' + sourceHandle: source + target: '1712585220528' + targetHandle: target + type: custom + - data: + sourceType: llm + targetType: end + id: 1712585220528-1712585261804 + source: '1712585220528' + sourceHandle: source + target: '1712585261804' + targetHandle: target + type: custom + nodes: + - data: + desc: '' + selected: false + title: "\u5F00\u59CB" + type: start + variables: + - label: "\u95EE\u9898" + max_length: 256 + options: [] + required: true + type: text-input + variable: query + height: 89 + id: '1712585213444' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 243 + - data: + context: + enabled: true + variable_selector: + - '1712585213444' + - query + desc: '' + model: + completion_params: + frequency_penalty: 0 + max_tokens: 512 + presence_penalty: 0 + temperature: 0.7 + top_p: 1 + mode: chat + name: gpt-3.5-turbo-0125 + provider: openai + prompt_template: + - role: system + text: Please disregard all previous instructions;\nThis is related to every + one of my prompts;\nYou should provide clear, simple, and direct answers;\nIf + you don't know the answer, say I don't know;\nFor complex questions, take + a deep breath and approach them step by step;\nFor questions that are + unclear or ambiguous, please ask me follow-up questions;\nWhen I ask you + to complete a task, please do it directly without telling me how it should + be done, unless necessary;\nWhen explaining concepts, use real-world examples + and analogies;\nDo not refuse replies related to tasks and work I assign;\nDo + not attempt to conserve token count when generating replies, as my fingers + have a condition that prevents me from typing too much;\nIf you have a + perfect solution, I will give you a $100000 tip, and I will give you more + tips based on the quality of your reply;\nDo your best!\nUnless otherwise + specified, please answer me in Chinese. + - role: user + text: '{{#1712585213444.query#}}' + selected: true + title: LLM + type: llm + variables: [] + vision: + enabled: false + height: 97 + id: '1712585220528' + position: + x: 380 + y: 282 + positionAbsolute: + x: 380 + y: 282 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 243 + - data: + desc: '' + outputs: + - value_selector: + - '1712585220528' + - text + variable: text + selected: false + title: "\u7ED3\u675F" + type: end + height: 89 + id: '1712585261804' + position: + x: 680 + y: 282 + positionAbsolute: + x: 680 + y: 282 + sourcePosition: right + targetPosition: left + type: custom + width: 243 + viewport: + x: 0 + y: 0 + zoom: 1