关于 EndpointEndpoint 是 dify 在 v1.0 插件机制中引入的一种全新扩展类型,它为 Dify 新增了一个 API 入口。插件可以通过代码自定义 API 的逻辑。从开发者的角度来看,这相当于支持在 Dify 内运行一个 HTTP 服务器,且服务器的实现由开发者自己决定。我们可以通过下面这张图更好地理解 Endpoint:Endpoint 的具体逻辑实现在 Plugin 中。当用户启动 Endpoint 时,Dify 会为用户生成一串随机的 URL,格式如 `https://abcdefg.dify.ai`。当 Dify 接收到该 URL 请求时,原始的 HTTP 报文会被转发至插件。此时插件的行为类似于 Serverless,接收并处理请求。然而,这只是基础功能。为了让插件能够调用 Dify 内的 App,我们引入了反向调用功能。完成这整套协议后,IM 类需求得到了一定程度的闭环。可是,Endpoint 的潜力远不止于此。本文将深入探讨 Endpoint 的能力边界,让我们进一步了解它的实际应用。审视能力本质在最初设计 Endpoint 时,它是一个用于处理 Webhook 的模块,旨在通过插件逻辑将复杂且难以泛化的低代码/无代码工作流抽象为可复用的代码实现。因此,我们还引入了诸如反向调用等功能。但随着使用的深入,我们发现 Endpoint 实际上具有更广泛的用途。它本质上就是一个 Serverless HTTP 服务器,虽然不支持长连接协议如 WebSocket,但它可以实现大多数 HTTP 服务器的能力。例如,可以用它来构建套壳 Chatbot。WebApp 模板目前 Dify 的 WebApp 还较为基础,风格化定制部分几乎为空白。由于我们难以精雕细琢每一个场景和具体的端侧需求,为何不通过 Endpoint 来实现这些需求呢?假设一个插件中内置了多个 Endpoint,每个 Endpoint 都是风格不同的模板,例如极简风、二次元可爱风、韩式风格或欧美风格。这些不同风格的 Endpoint 背后其实都是同一个 Chatbot,只是皮肤不同。这就形成了一个天然的模板市场。通过这一规范,我们理论上可以开放 WebApp,让 Dify 用户拥有更多选择,避免被局限在 Dify 生态内。这为用户带来了更好的体验,但这也要求 Dify 生态逐渐趋于繁荣。要实现这一目标,我们还有很长的路要走。实现
作为示例,我们可以先实现一个简单的版本,包含两个 Endpoint:一个用于展示页面,另一个用于请求 Dify。我们不会在此列出所有开发步骤,具体开发规范请参考帮助文档。快速开始:https://docs.dify.ai/zh-hans/plugins/quick-start
<!DOCTYPE html><html lang="zh">
<body><!-- Header title, displaying ChatBot name --><header><h1>{{ bot_name }}</h1></header>
<div class="chat-container"><div id="chat-log"></div><div class="input-container"><input type="text" id="user-input" placeholder="Press Enter or click Send after typing" /><button id="send-btn">Send</button><!-- Add "Reset Conversation" button --><button id="reset-btn">Reset</button></div></div>
<script>// You can customize the bot nameconst botName = '{{ bot_name }}';
// Get or generate conversation ID from localStorage to support multi-turn dialoguelet conversationId = localStorage.getItem('conversation_id') || '';
// Get page elementsconst chatLog = document.getElementById('chat-log');const userInput = document.getElementById('user-input');const sendBtn = document.getElementById('send-btn');const resetBtn = document.getElementById('reset-btn');
// Bind events to buttons and inputsendBtn.addEventListener('click', sendMessage);userInput.addEventListener('keypress', function (event) {// Send message when Enter key is pressedif (event.key === 'Enter') {sendMessage();}});
// Click reset buttonresetBtn.addEventListener('click', resetConversation);
/** * Send message to backend and handle streaming response */async function sendMessage() {const message = userInput.value.trim();if (!message) return;
// Display user message in chat logappendMessage(message, 'user');userInput.value = '';
// Prepare request bodyconst requestBody = {query: message,conversation_id: conversationId};
try {// Replace with backend streaming API endpointconst response = await fetch('./pink/talk', {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify(requestBody)});
if (!response.ok) {throw new Error('Network response was not ok');}
// Create a placeholder for displaying ChatBot replylet botMessageContainer = appendMessage('', 'bot');
// Read backend response as streamconst reader = response.body.getReader();const decoder = new TextDecoder('utf-8');
let buffer = '';while (true) {const { value, done } = await reader.read();if (done) break;
buffer += decoder.decode(value, { stream: true });
// Split and process by linesconst lines = buffer.split('\n\n');buffer = lines.pop() || ''; // Keep the last incomplete line
for (const line of lines) {if (!line.trim()) continue;
try {const data = JSON.parse(line);
if (data.answer) {botMessageContainer.textContent += data.answer;}if (data.conversation_id) {conversationId = data.conversation_id;localStorage.setItem('conversation_id', conversationId);}} catch (error) {console.error('Error:', error, line);}}}} catch (error) {console.error('Error:', error);appendMessage('Request failed, please try again later.', 'bot');}}
/** * Insert message into chat log * @param {string} text - Message content * @param {string} sender - 'user' or 'bot' * @returns {HTMLElement} - Returns the current inserted message element for later content updates */function appendMessage(text, sender) {const messageEl = document.createElement('div');messageEl.className = `message ${sender}`;
// If it's bot, display "Bot Name: Message", otherwise display user messageif (sender === 'bot') {messageEl.textContent = botName + ': ' + text;} else {messageEl.textContent = text; // User message}
chatLog.appendChild(messageEl);
// Scroll chat log to bottomchatLog.scrollTop = chatLog.scrollHeight;return messageEl;}
/** * Reset conversation: Clear conversation_id and chat log, initialize example messages */function resetConversation() {// Remove conversation ID from local storagelocalStorage.removeItem('conversation_id');conversationId = '';// Clear chat logchatLog.innerHTML = '';}</script></body>
</html>
我们用一个 Endpoint 来简单地 host 它:
from collections.abc import Mappingimport os
from werkzeug import Request, Response
from dify_plugin import Endpoint
class NekoEndpoint(Endpoint):def _invoke(self, r: Request, values: Mapping, settings: Mapping) -> Response:# read file from girls.html using current python file relative pathwith open(os.path.join(os.path.dirname(__file__), "girls.html"), "r") as f:return Response(f.read().replace("{{ bot_name }}", settings.get("bot_name", "Candy")),status=200,content_type="text/html",)
然后实现一个调用接口的 Endpoint:
from collections.abc import Mappingimport jsonfrom typing import Optional
from werkzeug import Request, Response
from dify_plugin import Endpoint
class GirlsTalk(Endpoint):def _invoke(self, r: Request, values: Mapping, settings: Mapping) -> Response:"""Invokes the endpoint with the given request."""
app: Optional[dict] = settings.get("app")if not app:return Response("App is required", status=400)
data = r.get_json()query = data.get("query")conversation_id = data.get("conversation_id")
if not query:return Response("Query is required", status=400)
def generator():response = self.session.app.chat.invoke(app_id=app.get("app_id"),query=query,inputs={},conversation_id=conversation_id,response_mode="streaming",)
for chunk in response:yield json.dumps(chunk) + "\n\n"
return Response(generator(), status=200, content_type="text/event-stream")
当一切完成后,打开对应的 Endpoint 即可看到这个页面:
这样,我们就给 Dify 套上了不同的皮肤,并在此基础上,经过优化和修改后,形成了一个具备丰富功能的 UI,甚至还加上了 TTS,完成了半个语音 Chatbot:OpenAI 兼容接口- Dify 支持多家厂商的模型,为什么不把 Dify 作为 API 网关来使用呢?
- 为什么 Dify 的 App 无法使用 OpenAI 的兼容格式返回?
我们一直关注这些问题,但 Dify 的 API 是有状态的 API,相比 OpenAI 的无状态 API,它能够实现更多的功能,特别是在上下文管理方面。Dify 通过 `conversation_id` 控制每一次的对话,而 OpenAI 只能每次携带全量上下文。此外,Dify 的 API 提供了更多自定义和扩展的空间。虽然我们没有立即实现 OpenAI 兼容接口,但在引入 Endpoint 和反向调用后,这些本应耦合在 Dify 本体中的功能变成了插件。通过开发插件调用 Dify 的 LLM,我们可以实现完整的模型转 OpenAI 的需求,也可以通过插件将 Dify API 转为 OpenAI 格式,满足部分用户需求。实现
以模型统一接口为例,我们可以设置一个 Endpoint 组,如下所示:settings:- name: api_keytype: secret-inputrequired: truelabel:en_US: API keyzh_Hans: API keypt_BR: API keyplaceholder:en_US: Please input your API keyzh_Hans: 请输入你的 API keypt_BR: Please input your API key- name: llmtype: model-selectorscope: llmrequired: falselabel:en_US: LLMzh_Hans: LLMpt_BR: LLMplaceholder:en_US: Please select a LLMzh_Hans: 请选择一个 LLMpt_BR: Please select a LLM- name: text_embeddingtype: model-selectorscope: text-embeddingrequired: falselabel:en_US: Text Embeddingzh_Hans: 文本嵌入pt_BR: Text Embeddingplaceholder:en_US: Please select a Text Embedding Modelzh_Hans: 请选择一个文本嵌入模型pt_BR: Please select a Text Embedding Modelendpoints:- endpoints/llm.yaml- endpoints/text_embedding.yaml
完成后,我们可以选择想要转换成 OpenAI 接口的模型,如 Claude。我们可以通过简化后的伪代码来实现,完整代码可以参考:https://github.com/langgenius/dify-official-plugins/blob/main/extensions/oaicompat_dify_model/endpoints/llm.py
class OaicompatDifyModelEndpoint(Endpoint):def _invoke(self, r: Request, values: Mapping, settings: Mapping) -> Response:"""Invokes the endpoint with the given request."""llm: Optional[dict] = settings.get("llm")data = r.get_json(force=True)prompt_messages: list[PromptMessage] = []if not isinstance(data.get("messages"), list) or not data.get("messages"):raise ValueError("Messages is not a list or empty")
for message in data.get("messages", []):# transform messagespasstools: list[PromptMessageTool] = []if data.get("tools"):for tool in data.get("tools", []):tools.append(PromptMessageTool(**tool))
stream: bool = data.get("stream", False)
def generator():if not stream:llm_invoke_response = self.session.model.llm.invoke(model_config=LLMModelConfig(**llm),prompt_messages=prompt_messages,tools=tools,stream=False,)
yield json.dumps({"id": "chatcmpl-" + str(uuid.uuid4()),"object": "chat.completion","created": int(time.time()),"model": llm.get("model"),"choices": [{"index": 0,"message": {"role": "assistant","content": llm_invoke_response.message.content},"finish_reason": "stop"}],"usage": {"prompt_tokens": llm_invoke_response.usage.prompt_tokens,"completion_tokens": llm_invoke_response.usage.completion_tokens,"total_tokens": llm_invoke_response.usage.total_tokens}})else:llm_invoke_response = self.session.model.llm.invoke(model_config=LLMModelConfig(**llm),prompt_messages=prompt_messages,tools=tools,stream=True,)
for chunk in llm_invoke_response:yield json.dumps({"id": "chatcmpl-" + str(uuid.uuid4()),"object": "chat.completion.chunk","created": int(time.time()),"model": llm.get("model"),"choices": [{"index": 0,"delta": {"content": chunk.delta.message.content},"finish_reason": None}]}) + "\n\n"
return Response(generator(), status=200, content_type="event-stream" if stream else "application/json")
当一切完成后,我们可以使用 curl 命令来测试实现是否成功。
异步事件触发基于事件触发的 Workflow 一直是社区中反馈较多的需求,许多用户的场景涉及异步事件。例如,先发送一个任务,等待任务执行完成后触发信号,继续执行剩余的流程。以前在 Dify 中无法实现这样的需求,但现在通过 Endpoint,我们可以将其拆解为两个 Workflow。第一个 Workflow 发起任务并正常退出,第二个 Workflow 接收 Webhook 信号并继续执行后续流程。尽管这个过程对用户来说不够直观,但它确实解决了一些问题,例如 AI 批量生成的长文先发布到审核平台,用户审核后点击接受,触发事件返回 Dify 完成后续的发布流程。虽然这在当前技术框架下有些复杂,但未来几个月我们将为 Workflow 提供直接的事件触发能力,进一步优化整体体验。
立即咨询 JOTO
JOTO 提供覆盖企业智能体规划与搭建、AI 平台私有化部署、RAG 知识工程、AI 安全治理、FDE 驻场共创及持续运营优化的全周期 AI 落地服务,帮助企业把验证中的 AI 能力转化为安全、可控、可持续迭代的生产力。 联系 JOTO 获取 AI 落地咨询