diff --git a/README.md b/README.md index 18fcbb31..0c8c45ab 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,7 @@ Debugging no longer means probing an opaque database — it becomes a determinis | [`CHANGELOG.md`](./CHANGELOG.md) | Release notes and version history | | [`openclaw.plugin.json`](./openclaw.plugin.json) | OpenClaw plugin manifest and configuration schema | | [`src/adapters/gateway-client/README.md`](./src/adapters/gateway-client/README.md) | Gateway-based adapter guide for Codex, Claude Code, Dify, LangGraph, and custom agents | +| [`docs/DIFY-ADAPTER.md`](./docs/DIFY-ADAPTER.md) | Dify workflow adapter guide built on the Gateway adapter kit | --- diff --git a/README_CN.md b/README_CN.md index 9c90f15e..04c8c49b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -512,6 +512,7 @@ export MEMORY_TENCENTDB_GATEWAY_API_KEY="<与 Gateway 同一份密钥>" | [`CHANGELOG.md`](./CHANGELOG.md) | 版本变更记录 | | [`openclaw.plugin.json`](./openclaw.plugin.json) | OpenClaw 插件声明与配置 Schema | | [`src/adapters/gateway-client/README.md`](./src/adapters/gateway-client/README.md) | 面向 Codex、Claude Code、Dify、LangGraph 和自定义 Agent 的 Gateway 适配指南 | +| [`docs/DIFY-ADAPTER.md`](./docs/DIFY-ADAPTER.md) | 基于 Gateway 适配工具包的 Dify workflow 接入说明 | --- ## 社区与贡献 diff --git a/docs/DIFY-ADAPTER.md b/docs/DIFY-ADAPTER.md new file mode 100644 index 00000000..d6611813 --- /dev/null +++ b/docs/DIFY-ADAPTER.md @@ -0,0 +1,118 @@ +# Dify Adapter Guide + +Related issue: https://github.com/TencentCloud/TencentDB-Agent-Memory/issues/235 + +This follow-up builds on the Gateway client adapter kit from #316. It does not +add a second Gateway SDK or a parallel cross-platform abstraction. The Dify +adapter only maps Dify workflow variables to the #316 `GatewayMemoryClient` +boundary and the existing Gateway `/recall` and `/capture` routes. + +## Scope + +Included: + +- Dify workflow input mapping for recall before the LLM node. +- Dify answer mapping for capture after the LLM node. +- Stable Dify `session_key` construction from platform, user, conversation, and + optional session IDs. +- HTTP-only Dify node examples for users who do not run TypeScript. + +Not included: + +- A general-purpose Gateway SDK beyond #316. +- MCP server, Python SDK, Codex or Claude hooks. +- Core memory runtime changes. +- CI or packaging changes. + +## TypeScript Adapter + +Use the TypeScript adapter when your Dify workflow can call a small Node.js +bridge service. The adapter accepts either an injected Dify memory port or the +same `GatewayMemoryClientOptions` shape introduced by #316. + +```ts +import { createDifyWorkflowMemoryAdapter } from "@tencentdb-agent-memory/memory-tencentdb"; + +const memory = createDifyWorkflowMemoryAdapter({ + gateway: { + baseUrl: "http://127.0.0.1:8420", + apiKey: process.env.TDAI_GATEWAY_API_KEY, + }, +}); + +const recalled = await memory.recall({ + query: inputs.query, + conversation_id: conversationId, + user: userId, +}); + +// Inject recalled.memory_context into the Dify prompt template. + +await memory.capture({ + query: inputs.query, + answer: llmAnswer, + conversation_id: conversationId, + user: userId, +}); +``` + +## HTTP-Only Dify Nodes + +If you do not run TypeScript, use two HTTP request nodes. + +Before the LLM node: + +```http +POST http://127.0.0.1:8420/recall +Content-Type: application/json +Authorization: Bearer ${TDAI_GATEWAY_API_KEY} + +{ + "query": "{{query}}", + "session_key": "dify:{{user}}:{{conversation_id}}", + "user_id": "{{user}}" +} +``` + +Use `context` from the response as `memory_context`. + +After the LLM node: + +```http +POST http://127.0.0.1:8420/capture +Content-Type: application/json +Authorization: Bearer ${TDAI_GATEWAY_API_KEY} + +{ + "user_content": "{{query}}", + "assistant_content": "{{answer}}", + "session_key": "dify:{{user}}:{{conversation_id}}", + "user_id": "{{user}}" +} +``` + +## Field Mapping + +| Dify field | Gateway field | Notes | +| --- | --- | --- | +| `query`, `inputs.query`, `user_content`, `prompt`, `message` | `query` / `user_content` | Recall and capture accept common Dify variable names. | +| `answer`, `assistant_content`, `response`, `output` | `assistant_content` | Required for capture. | +| `user`, `user_id` | `user_id` and `session_key` part | Falls back to `default_user`. | +| `conversation_id`, `session_id` | `session_key` part | Falls back to `default_conversation`. | +| `messages` | `messages` | Forwarded to `/capture` when present. | + +## Validation + +Run adapter-focused tests: + +```bash +npm test -- src/adapters/gateway-client/gateway-client.test.ts src/adapters/dify/index.test.ts +``` + +Run the full suite before opening or updating a PR: + +```bash +npm test +npm run build +git diff --check +``` diff --git a/index.ts b/index.ts index 054d3a4b..b6fa8817 100644 --- a/index.ts +++ b/index.ts @@ -46,16 +46,23 @@ import { import { resolveOpenClawStateDir } from "./src/utils/openclaw-state-dir.js"; export { + DifyWorkflowMemoryAdapter, GatewayMemoryClient, GatewayMemoryClientError, + createDifyWorkflowMemoryAdapter, createGatewayPlatformAdapter, -} from "./src/adapters/gateway-client/index.js"; +} from "./src/adapters/index.js"; export type { + DifyCaptureResult, + DifyGatewayMemoryPort, + DifyRecallResult, + DifyWorkflowInput, + DifyWorkflowMemoryAdapterOptions, GatewayMemoryClientOptions, GatewayPlatformAdapter, GatewayPlatformAdapterOptions, GatewayPlatformContext, -} from "./src/adapters/gateway-client/index.js"; +} from "./src/adapters/index.js"; const TAG = "[memory-tdai]"; diff --git a/src/adapters/dify/index.test.ts b/src/adapters/dify/index.test.ts new file mode 100644 index 00000000..742bda73 --- /dev/null +++ b/src/adapters/dify/index.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; + +import { + DifyWorkflowMemoryAdapter, + type DifyGatewayMemoryPort, +} from "./index.js"; + +function makeClient(calls: unknown[]): DifyGatewayMemoryPort { + return { + async recall(body) { + calls.push({ method: "recall", body }); + return { context: "memory context", memory_count: 2, strategy: "hybrid" }; + }, + async capture(body) { + calls.push({ method: "capture", body }); + return { l0_recorded: 2, scheduler_notified: true }; + }, + }; +} + +describe("DifyWorkflowMemoryAdapter", () => { + it("maps Dify workflow input to Gateway recall", async () => { + const calls: unknown[] = []; + const adapter = new DifyWorkflowMemoryAdapter({ client: makeClient(calls) }); + + const result = await adapter.recall({ + inputs: { query: "project preference" }, + conversation_id: "conv 1", + user: "user/42", + }); + + expect(result).toEqual({ + session_key: "dify:user_42:conv_1", + memory_context: "memory context", + memory_count: 2, + strategy: "hybrid", + }); + expect(calls).toEqual([ + { + method: "recall", + body: { + query: "project preference", + session_key: "dify:user_42:conv_1", + user_id: "user/42", + }, + }, + ]); + }); + + it("maps Dify answer output to Gateway capture", async () => { + const calls: unknown[] = []; + const adapter = new DifyWorkflowMemoryAdapter({ + client: makeClient(calls), + platform: "dify-cloud", + }); + + const result = await adapter.capture({ + query: "remember this", + answer: "stored", + conversation_id: "conv", + session_id: "run-1", + user_id: "u", + messages: [{ role: "user", content: "remember this" }], + }); + + expect(result).toEqual({ + session_key: "dify-cloud:u:conv:run-1", + l0_recorded: 2, + scheduler_notified: true, + }); + expect(calls).toEqual([ + { + method: "capture", + body: { + user_content: "remember this", + assistant_content: "stored", + session_key: "dify-cloud:u:conv:run-1", + session_id: "run-1", + user_id: "u", + messages: [{ role: "user", content: "remember this" }], + }, + }, + ]); + }); + + it("uses the #316 GatewayMemoryClient boundary for HTTP integration", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const adapter = new DifyWorkflowMemoryAdapter({ + gateway: { + baseUrl: "http://127.0.0.1:8420/", + apiKey: "token", + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init: init ?? {} }); + return new Response(JSON.stringify({ context: "from gateway", memory_count: 1 }), { + headers: { "Content-Type": "application/json" }, + }); + }, + }, + }); + + await expect(adapter.recall({ + query: "latest project memory", + conversation_id: "conv", + user_id: "u", + })).resolves.toMatchObject({ + memory_context: "from gateway", + session_key: "dify:u:conv", + }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("http://127.0.0.1:8420/recall"); + expect(calls[0].init.headers).toMatchObject({ + "Content-Type": "application/json", + Authorization: "Bearer token", + }); + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + query: "latest project memory", + session_key: "dify:u:conv", + user_id: "u", + }); + }); + + it("keeps the platform adapter scoped to recall and capture routes", async () => { + const calls: Array<{ url: string; body: unknown }> = []; + const adapter = new DifyWorkflowMemoryAdapter({ + gateway: { + baseUrl: "http://gateway.local", + fetchImpl: async (url, init) => { + calls.push({ + url: String(url), + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + return new Response(JSON.stringify({ l0_recorded: 1, scheduler_notified: true }), { + headers: { "Content-Type": "application/json" }, + }); + }, + }, + }); + + await adapter.capture({ + query: "remember", + answer: "done", + conversation_id: "conv", + user: "user", + }); + + expect(calls).toEqual([ + { + url: "http://gateway.local/capture", + body: { + user_content: "remember", + assistant_content: "done", + session_key: "dify:user:conv", + user_id: "user", + }, + }, + ]); + }); + + it("rejects incomplete capture payloads", async () => { + const adapter = new DifyWorkflowMemoryAdapter({ client: makeClient([]) }); + + await expect(adapter.capture({ query: "only user text" })).rejects.toThrow( + "Dify capture requires `assistant_content` or `answer`", + ); + }); + + it("requires either an injected Dify port or GatewayMemoryClient options", () => { + expect(() => new DifyWorkflowMemoryAdapter({})).toThrow( + "DifyWorkflowMemoryAdapter requires either `client` or `gateway` options", + ); + }); +}); diff --git a/src/adapters/dify/index.ts b/src/adapters/dify/index.ts new file mode 100644 index 00000000..e8e8bd65 --- /dev/null +++ b/src/adapters/dify/index.ts @@ -0,0 +1,172 @@ +import { + GatewayMemoryClient, + type GatewayMemoryClientOptions, +} from "../gateway-client/index.js"; +import type { CaptureRequest, CaptureResponse, RecallRequest, RecallResponse } from "../../gateway/types.js"; + +export interface DifyGatewayMemoryPort { + recall(body: RecallRequest): Promise; + capture(body: CaptureRequest): Promise; +} + +export interface DifyWorkflowMemoryAdapterOptions { + gateway?: GatewayMemoryClientOptions; + client?: DifyGatewayMemoryPort; + platform?: string; + defaultUserId?: string; +} + +export interface DifyWorkflowInput { + query?: string; + user_content?: string; + assistant_content?: string; + answer?: string; + conversation_id?: string; + session_id?: string; + user?: string; + user_id?: string; + inputs?: Record; + messages?: unknown[]; +} + +export interface DifyRecallResult { + session_key: string; + memory_context: string; + memory_count: number; + strategy?: string; +} + +export interface DifyCaptureResult extends CaptureResponse { + session_key: string; +} + +export class DifyWorkflowMemoryAdapter { + private readonly client: DifyGatewayMemoryPort; + private readonly platform: string; + private readonly defaultUserId: string; + + constructor(opts: DifyWorkflowMemoryAdapterOptions) { + if (!opts.client && !opts.gateway) { + throw new Error("DifyWorkflowMemoryAdapter requires either `client` or `gateway` options"); + } + this.client = opts.client ?? new GatewayMemoryClient(opts.gateway!); + this.platform = opts.platform ?? "dify"; + this.defaultUserId = opts.defaultUserId ?? "default_user"; + } + + /** + * Call before the Dify LLM node. Return `memory_context` as a workflow + * variable and inject it into the system prompt or user prompt template. + */ + async recall(input: DifyWorkflowInput): Promise { + const query = readString(input, "query", "user_content", "prompt", "message"); + if (!query) throw new Error("Dify recall requires `query` or `inputs.query`"); + + const identity = this.resolveIdentity(input); + const result = await this.client.recall({ + query, + session_key: identity.sessionKey, + user_id: identity.userId, + }); + + return { + session_key: identity.sessionKey, + memory_context: result.context, + memory_count: result.memory_count ?? 0, + strategy: result.strategy, + }; + } + + /** + * Call after the Dify answer node. This stores the completed turn in L0 and + * lets the Gateway schedule L1/L2/L3 processing. + */ + async capture(input: DifyWorkflowInput): Promise { + const userContent = readString(input, "user_content", "query", "prompt", "message"); + const assistantContent = readString(input, "assistant_content", "answer", "response", "output"); + if (!userContent) throw new Error("Dify capture requires `user_content` or `query`"); + if (!assistantContent) throw new Error("Dify capture requires `assistant_content` or `answer`"); + + const identity = this.resolveIdentity(input); + const result = await this.client.capture({ + user_content: userContent, + assistant_content: assistantContent, + session_key: identity.sessionKey, + session_id: identity.sessionId, + user_id: identity.userId, + messages: input.messages, + } satisfies CaptureRequest); + + return { + ...result, + session_key: identity.sessionKey, + }; + } + + buildSessionKey(input: DifyWorkflowInput): string { + return this.resolveIdentity(input).sessionKey; + } + + private resolveIdentity(input: DifyWorkflowInput): { + userId: string; + conversationId: string; + sessionId?: string; + sessionKey: string; + } { + const userId = readString(input, "user_id", "user") ?? this.defaultUserId; + const conversationId = + readString(input, "conversation_id", "conversationId") ?? + readString(input, "session_id", "sessionId") ?? + "default_conversation"; + const sessionId = readString(input, "session_id", "sessionId"); + return { + userId, + conversationId, + sessionId, + sessionKey: buildDifySessionKey({ + platform: this.platform, + userId, + conversationId, + sessionId, + }), + }; + } +} + +export function createDifyWorkflowMemoryAdapter( + opts: DifyWorkflowMemoryAdapterOptions, +): DifyWorkflowMemoryAdapter { + return new DifyWorkflowMemoryAdapter(opts); +} + +function readString(input: DifyWorkflowInput, ...keys: string[]): string | undefined { + for (const key of keys) { + const direct = (input as Record)[key]; + if (typeof direct === "string" && direct.trim()) return direct; + const nested = input.inputs?.[key]; + if (typeof nested === "string" && nested.trim()) return nested; + } + return undefined; +} + +function buildDifySessionKey(parts: { + platform: string; + userId: string; + conversationId: string; + sessionId?: string; +}): string { + const platform = sanitizeSessionKeyPart(parts.platform || "dify"); + const user = sanitizeSessionKeyPart(parts.userId || "default_user"); + const conversation = sanitizeSessionKeyPart(parts.conversationId || "default_conversation"); + const session = parts.sessionId ? `:${sanitizeSessionKeyPart(parts.sessionId)}` : ""; + return `${platform}:${user}:${conversation}${session}`; +} + +function sanitizeSessionKeyPart(value: string): string { + const sanitized = value + .trim() + .replace(/[^A-Za-z0-9_.:-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + return sanitized || "unknown"; +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index a0f3961c..b2ee4943 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -31,3 +31,13 @@ export type { GatewayPlatformAdapterOptions, GatewayPlatformContext, } from "./gateway-client/index.js"; + +// Dify Workflow adapter +export { DifyWorkflowMemoryAdapter, createDifyWorkflowMemoryAdapter } from "./dify/index.js"; +export type { + DifyCaptureResult, + DifyGatewayMemoryPort, + DifyRecallResult, + DifyWorkflowInput, + DifyWorkflowMemoryAdapterOptions, +} from "./dify/index.js";