wtfai
v1.8.8
Published
Agent 工作流 SDK,为第三方 Web 开发者提供简洁的 API 来集成工作流执行、文件上传和会话管理功能。
Readme
wtfai
Agent 工作流 SDK,为第三方 Web 开发者提供简洁的 API 来集成工作流执行、文件上传和会话管理功能。
安装
npm install wtfai
# 或
pnpm add wtfai使用前准备
前往
https://www.ourteacher.cc/联系客服申请开通 API 访问权限,获取 API Key;您的后端需要代理
https://llm-api.ourschool.cc/workflows/开头的接口,在代理过程中需要给请求头添加x-tenant-secret,值为 API Key;后续将
baseUrl改为您的后端代理地址即可;为方便开发调试,前端可以在创建 Wtfai 实例时传入
tenantSecret,值为 API Key,切记不要在生产环境使用。
快速开始
import Wtfai from 'wtfai'
// 1. 创建客户端
const client = new Wtfai({
baseUrl: 'https://api.example.com',
})
// 2. 创建会话
const session = client.createSession('workflow-id')
// 3. 发送消息
await session.send({ parts: [{ type: 'text', text: '你好' }] })API 文档
Wtfai
SDK 主入口类。
构造函数
const client = new Wtfai({
baseUrl: string, // 必填,API 服务器地址
timeout?: number, // 可选,请求超时时间(毫秒)
headers?: Record<string, string>, // 可选,自定义请求头
tenantSecret?: string, // 可选,租户密钥 (⚠️ 仅用于调试,禁止在生产环境使用)
})方法
getWorkflows(page?: number, pageSize?: number)
获取工作流列表。
// 分页获取工作流,默认 page=1, pageSize=10
const { items, total } = await client.getWorkflows(1, 20)
console.log(`共 ${total} 个工作流`)getWorkflow(id: string)
获取工作流详情。
const workflow = await client.getWorkflow('workflow-id')
console.log(workflow.name, workflow.description)createSession(workflowId: string, options?: SessionOptions)
创建工作流会话。
// 新会话
const session = client.createSession('workflow-id')
// 恢复已有会话
const session = client.createSession('workflow-id', {
threadId: 'existing-thread-id',
})WorkflowSession
工作流会话类,管理消息状态和执行流程。
方法
on(event, listener)
监听事件。事件语义与后端 SSE 推送保持一致(见 docs/sse-event-protocol.md)。
事件说明与常见用法
start:工作流开始执行。会返回后端确认的threadId;title仅在新会话时返回(默认为工作流名)。- 常见用法:首次拿到
threadId后持久化,便于恢复会话。
- 常见用法:首次拿到
nodeStart:某个节点开始执行(排除了 LangGraph 内置的__start__/__end__)。- 常见用法:在 UI 上标记「当前节点」,或显示步骤进度。
nodeEnd:某个节点执行结束,可能包含variables和/或messages。- 常见用法:更新节点级结果或收集中间产物。
- 注意:后端当前只透传 billing 相关变量;
brain/loop节点的消息已在流式 token 中渲染,这里会省略以避免重复。
token:LLM 流式 token。isReasoning表示推理内容(例如 DeepSeek 的 reasoning_content)。- 常见用法:实现打字机效果;区分显示「推理」与「最终输出」。
loading:仅在 Loading 节点触发,message为配置的提示语(纯字符串)。- 常见用法:展示阶段性加载提示。
title:异步生成的会话标题(仅新会话)。通常在start之后稍晚到达。- 常见用法:更新会话列表标题/面包屑。
complete:本次执行成功结束。- 常见用法:停止输入框 loading、允许再次发送。
error:执行出错时触发(错误时不会再触发complete)。- 常见用法:toast 提示、错误上报、兜底 UI。
session.on('start', ({ threadId, title }) => {
// 工作流开始执行,获取到后端确认的 threadId
// title 只有新会话才会有(默认为工作流名称)
// 建议持久化 threadId,便于恢复会话
})
session.on('nodeStart', ({ nodeId }) => {
// 节点开始执行
})
session.on('nodeEnd', ({ nodeId, variables, messages }) => {
// 节点执行结束
// variables: 目前只包含 billing 相关变量
// messages: brain/loop 节点通常已在 token 中渲染,可能为空
})
session.on('token', ({ nodeId, content, isReasoning }) => {
// 收到流式 token(用于打字机效果)
// isReasoning 为 true 时表示推理内容
})
session.on('loading', ({ nodeId, message }) => {
// Loading 节点的提示语(纯字符串)
console.log('Loading:', message)
})
session.on('title', ({ title }) => {
// 收到标题事件,用于更新会话标题
console.log('会话标题:', title)
})
session.on('complete', () => {
// 工作流执行完成(成功)
})
session.on('error', ({ message }) => {
// 发生错误
})
session.on('contentAction', ({ type, payload }) => {
// 内容区域(如 Markdown)触发的自定义动作
if (type === 'workflow') {
const { workflowId, params } = payload
console.log('跳转工作流:', workflowId, '参数:', params)
}
})Markdown 交互 (自定义协议)
SDK 的 Markdown 组件支持通过特殊协议触发 contentAction 事件,方便在内容中嵌入交互按钮或链接:
- 跳转工作流:使用
workflow:协议。 格式:[文字](workflow:工作流ID?参数1=值1&参数2=值2)示例:[查看详情](workflow:cm0p1234?from=chat)
当用户点击此类链接时,SDK 不会执行页面跳转,而是触发 contentAction 事件,由宿主应用决定如何处理(如切换侧边栏、打开弹窗等)。
结构化内容处理 (WorkflowRegistry)
为了在聊天内容中更美观地展示工作流引用,SDK 提供了 <workflow> 自定义标签和全局注册机制。
1. 全局注册工作流信息
在应用顶层(或页面容器层)使用 WorkflowRegistry 注入工作流的展示名称和图标:
import { WorkflowRegistry } from 'wtfai';
import { RobotOutlined } from '@ant-design/icons';
const MY_WORKFLOWS = {
'wf_week_report': {
name: '自动化周报',
icon: <RobotOutlined />
},
};
// 在容器层包裹一次即可
<WorkflowRegistry mapping={MY_WORKFLOWS}>
<App />
</WorkflowRegistry>2. 在 Markdown 中使用
通过自定义标签 <workflow id="xxx" /> 引用工作流:
- 基本用法:
<workflow id="wf_week_report" /> - 自定义文字:
<workflow id="wf_week_report">立即查看</workflow>
渲染效果:
SDK 会自动将其渲染为一个美观的“药丸状”卡片按钮,包含图标和名称。如果 WorkflowRegistry 中没有匹配到 ID,则会降级显示标签内容或 ID。
点击该卡片会触发 contentAction 事件,type 为 'workflow'。
3. 内容包装器 (contentWrapper)
WorkflowRegistry 支持 contentWrapper 属性,允许开发者在渲染 Markdown 内容前对其进行包装或增强。目前支持对 code 块进行包装。
配置类型:
export type ContentWrapperConfig = {
code?: (
props: ComponentProps, // props 包含 lang, children, streamStatus 等
) => ((children: ReactNode) => ReactNode) | null | undefined | false
}使用示例:
比如,你想给所有的 html:run 类型的代码预览块增加一个自定义的消息提示:
import { WorkflowRegistry } from 'wtfai';
<WorkflowRegistry
contentWrapper={{
code: (props) => {
// 只有特定语言才进行包装
if (props.lang === 'html:run') {
return (children) => (
<div className="my-custom-wrapper">
<div className="wrapper-header">这是我自定义加的内容</div>
{children}
</div>
)
}
return false // 返回 false 表示不包装,由 SDK 按默认方式渲染
},
}}
>
<ChatPage />
</WorkflowRegistry>这种机制非常适合在不修改 SDK 源码的前提下,为特定类型的内容增加业务相关的 UI 装饰(如操作按钮、免责声明、权限校验提示等)。
4. 消息导出
SDK 提供 exportMessages 用于导出当前会话消息,并会把 Mermaid 等图表内容导出为图片数据。导出结果不会自动下载文件;如需生成文件内容,可使用 createExportedMessagesBlob 得到 Blob,后续上传、保存或下载由宿主应用自行决定。
配置导出上下文
导出需要知道消息数据和对应的 Markdown 容器 DOM。通常在渲染消息列表的外层传入 exportConfig:
import { useRef } from 'react'
import {
WorkflowRegistry,
exportMessages,
createExportedMessagesBlob,
type WorkflowExportConfig,
} from 'wtfai'
const messageListRef = useRef<HTMLDivElement>(null)
const exportConfig: WorkflowExportConfig = {
containerRef: messageListRef,
messages,
}
async function handleExport() {
const payload = await exportMessages(exportConfig)
const blob = createExportedMessagesBlob(payload)
// SDK 只返回数据,不假设业务如何消费。
// 下面仅演示浏览器下载,实际也可以上传到服务器或写入 IndexedDB。
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `chat-export-${Date.now()}.json`
link.click()
URL.revokeObjectURL(url)
}
return (
<div ref={messageListRef}>
<WorkflowRegistry exportConfig={exportConfig}>
<MessageList messages={messages} />
</WorkflowRegistry>
</div>
)导出结果结构
每条消息会保留原始内容,同时提供一个普通 Markdown 渲染器可直接展示的内容字段:
type ExportedMessage = {
id: string
role: 'user' | 'assistant'
content: string
renderableContent: string
attachments?: unknown
chartImages: Array<{
kind: string
format: 'png'
dataUrl: string
}>
}content:原始消息内容,不做修改。renderableContent:可直接渲染的 Markdown 内容。SDK 会把已成功导出的 Mermaid 等代码块替换为 Markdown 图片标记,图片地址为 base64 data URL。chartImages:结构化的图表图片数据,便于业务侧单独存储、上传或二次处理。
最佳实践(推荐组合)
- 打字机效果:使用
token拼接流式内容。 - 消息列表:优先由
token推进 AI 回复;nodeEnd仅用于补齐非 brain/loop 节点的消息。 - 加载状态:
loading展示阶段性提示,complete/error关闭 loading 并解锁输入。 - 会话管理:在
start保存threadId,title更新会话标题。
off(event, listener)
移除事件监听。
const onToken = (data) => console.log(data)
session.on('token', onToken)
// 不需要时移除监听
session.off('token', onToken)send(input: SendInput)
发送消息执行工作流。
// 基础用法:纯文本
await session.send({
parts: [
{ type: 'text', text: '你好' }
]
})
// 图片 + 文本(使用 File)
await session.send({
parts: [
{ type: 'text', text: '请分析下面的图片:' },
{ type: 'image', file: imageFile },
{ type: 'text', text: '说明这张图的内容' }
]
})
// 图片 + 文本(使用 URL)
await session.send({
parts: [
{ type: 'text', text: '请对比这两张图片:' },
{ type: 'image', url: 'https://example.com/img1.jpg' },
{ type: 'image', url: 'https://example.com/img2.jpg' }
]
})
// 完整示例:文本 + 图片 + 文档
await session.send({
parts: [
{ type: 'text', text: '请对比下面两张图片:' },
{ type: 'image', file: imageFile1 },
{ type: 'image', file: imageFile2 },
{ type: 'text', text: '然后参考这份报告:' },
{
type: 'document',
file: pdfFile,
filename: 'report.pdf'
},
{ type: 'text', text: '说明第一张图的问题在哪里' }
]
})
// 使用 blob URL
await session.send({
parts: [
{ type: 'text', text: '分析这张图:' },
{ type: 'image', url: blobUrl }, // 会自动上传
]
})说明:
parts数组中的元素会按顺序传递给 LLM,使大模型能准确理解位置指代(如"下面的图片"、"第一张图")image和document类型必须提供file或url之一- 图片 File 会自动压缩到 1920px 以内,质量 0.8
- blob URL 会自动上传到服务器
restore()
恢复历史会话(需要在创建 session 时传入 threadId)。
const session = client.createSession('workflow-id', {
threadId: 'existing-thread-id',
})
await session.restore()
// 会将历史消息写入 state,可通过 getState 获取updateSessionTitle(title: string)
更新会话标题(手动修改)。
await session.updateSessionTitle('新的会话标题')
// 会更新服务端变量,并触发 title 事件getState()
获取当前状态快照。
const state = session.getState()
console.log(state.messages)
console.log(state.isExecuting)abort()
中止当前执行。
session.abort()dispose()
释放会话资源:移除所有事件监听并中止执行,同时清空会话内部状态。被释放的 session 不可再使用。
session.dispose()说明:
abort()仅用于中断当前执行(比如用户点击停止生成)dispose()用于彻底清理会话(组件卸载、切换会话时),调用后不可再复用该 session
setWorkflowVariables(values)
更新服务端会话变量。
await session.setWorkflowVariables({
key: 'value',
userPreference: { theme: 'dark' }
})getWorkflowVariables()
获取服务端会话变量。
const vars = await session.getWorkflowVariables()
console.log(vars.userPreference)registerIframeMethods(methods)
注册可供 Iframe (Guest) 调用的方法。
session.registerIframeMethods({
// 定义一个获取用户信息的方法
getUserInfo: async () => {
return { name: 'Alice', id: 123 }
},
// 定义一个打开弹窗的方法
openModal: (type) => {
setModalVisible(type)
}
})Iframe SDK (Guest Side)
我们在 packages/sdk/iframe-sdk.js 提供了轻量级的 SDK,供嵌入的 HTML 工具使用。
该 SDK 不需要构建工具,直接通过 <script> 标签引入即可。
引入 SDK
<script src="/iframe-sdk.js"></script>使用方法
SDK 会在全局挂载 IframeBridge 对象。
IframeBridge.call(method, params?)
调用宿主 (Host) 方法。
// 调用自定义方法
const userInfo = await IframeBridge.call('getUserInfo');
// 调用宿主方法并传参
await IframeBridge.call('log', ['Hello from iframe']);IframeBridge.hasMethod(methodName)
检查宿主是否支持某个方法。
const supportsLog = await IframeBridge.hasMethod('log');
if (supportsLog) {
// ...
}IframeBridge.saveData(key, value)
持久化存储数据(存储在当前工作流会话的 variables 中)。
await IframeBridge.saveData('draft', { content: '...' });IframeBridge.loadData(key)
读取持久化数据。
const draft = await IframeBridge.loadData('draft');IframeBridge.executeWorkflow(workflowId, input)
临时/嵌套执行另一个工作流。
const result = await IframeBridge.executeWorkflow('target-workflow-id', {
parts: [
{ type: 'image', url: 'https://...' }
]
});
console.log(result); // 执行产生的所有消息IframeBridge.updateSessionTitle(title)
更新当前会话的标题。
await IframeBridge.updateSessionTitle('新的标题');IframeBridge.uploadFile(file, options?)
上传文件到云存储,返回 CDN URL。
// 从 file input 上传
const fileInput = document.querySelector('input[type="file"]');
const url = await IframeBridge.uploadFile(fileInput.files[0]);
console.log('文件 URL:', url);
// 从 canvas 上传截图
canvas.toBlob(async (blob) => {
const file = new File([blob], 'screenshot.png', { type: 'image/png' });
const url = await IframeBridge.uploadFile(file);
console.log('截图 URL:', url);
});
// 指定资源类型
const url = await IframeBridge.uploadFile(file, {
resourceType: 'conversation' // 'conversation' | 'workflow' | 'knowledge-base'
});说明:
- 图片类型会自动压缩到 1920px 以内
- 音视频文件会自动获取时长信息
- 返回的是可直接访问的 CDN URL
Session Data CRUD API
用于会话级数据的持久化存储,与 saveData/loadData 不同,这是一套完整的 CRUD 接口,支持集合(Collection)概念,适合存储结构化数据。
与 saveData/loadData 的区别:
saveData/loadData存储在工作流 state 中(适合简单 key-value)- Session Data API 存储在独立数据表中(适合结构化数据、列表数据)
IframeBridge.createRecord(collection, data, options?)
在指定集合中创建一条记录。可以通过 options.entityId 字段进行二级分类归属(如老师 ID、学生 ID),后续查询时利用此字段能大幅提升检索性能。
// options = { entityId?: string }
const id = await IframeBridge.createRecord('notes', {
title: 'My Note',
content: 'Hello World',
createdAt: Date.now()
}, { entityId: 'user_123' }); // 可选的 entityId
console.log('创建成功, ID:', id);IframeBridge.createRecords(collection, dataList, options?)
批量在指定集合中创建多条记录。支持传入可选的配置项如 entityId。
// options = { entityId?: string }
const ids = await IframeBridge.createRecords('logs', [
{ level: 'info', message: 'Task started' },
{ level: 'success', message: 'Task finished' }
], { entityId: 'user_123' });
console.log('创建成功, IDs:', ids);IframeBridge.getRecord(collection, id)
获取单条记录。
const data = await IframeBridge.getRecord('notes', id);
if (data) {
console.log('记录内容:', data.title, data.content);
} else {
console.log('记录不存在');
}IframeBridge.updateRecord(collection, id, data)
更新记录(完整替换)。
await IframeBridge.updateRecord('notes', id, {
title: 'Updated Title',
content: 'New content',
updatedAt: Date.now()
});IframeBridge.deleteRecord(collection, id)
删除单条记录。
await IframeBridge.deleteRecord('notes', id);IframeBridge.listRecords(collection, options?)
分页查询集合中的记录。可以通过传入 entityId 来极速筛选归属于特定实体的数据。
// options = { page?: number, pageSize?: number, order?: 'ASC' | 'DESC', entityId?: string }
const result = await IframeBridge.listRecords('notes', {
page: 1,
pageSize: 10,
order: 'DESC',
entityId: 'user_123' // 可选,利用组合索引极速过滤
});
console.log(`共 ${result.total} 条记录`);
result.records.forEach(r => {
console.log(r.id, r.data.title);
});IframeBridge.clearCollection(collection)
清空集合中的所有记录。
const count = await IframeBridge.clearCollection('notes');
console.log(`已删除 ${count} 条记录`);使用场景:
- 待办事项列表
- 用户收藏/历史记录
- 表单草稿
- 游戏存档
HTTP API
以下 API 可直接通过 HTTP 调用,无需使用 SDK。
中断工作流执行
中断正在运行的工作流。工作流会返回 error 事件携带中断原因后关闭连接。
请求
POST /workflows/:workflowId/interrupt
Content-Type: application/json
{
"threadId": "会话ID",
"reason": "中断原因"
}响应
{
"success": true,
"local": true // true=本地实例中断, false=通过集群广播
}说明:
- 支持集群模式,会通过 Redis Pub/Sub 广播中断信号到所有实例
- 工作流被中断后会触发
error事件,message为传入的reason - 如果找不到对应的执行会话,返回 404
RealtimeService
提供跨端的实时消息总线功能(通过 client.realtime 访问)。
subscribe(topic: string, onEvent: (event: RealtimeEvent) => void)
订阅实时消息流。适合大屏端监听由其他端发送的指令或通知。
- 自动管理:方法内部会自动计算并附加当前浏览器的设备指纹(deviceId),激活在线状态追踪。
- 返回值:返回一个对象,包含
unsubscribe()方法用于断开连接。
const sub = await client.realtime.subscribe('class:101', (event) => {
console.log('收到实时更新:', event.data);
// event.event 为事件名称,event.data 为业务数据
});
// 当页面销毁或不需要监听时,请务必手动释放:
// sub.unsubscribe();push(topic: string, event: string, data: any)
推送实时消息。适合手机端(或其他客户端)发送指令。
await client.realtime.push('class:101', 'msgA', {
foo: 'bar'
});getPresence(topic: string)
获取指定主题下的在线状态详情(如当前有多少个大屏在线)。
const { onlineCount, clients } = await client.realtime.getPresence('class:101');
console.log(`当前在线设备数: ${onlineCount}`);UploadService
文件上传服务(通过 client.upload 访问)。
uploadFile(params)
上传文件。
const url = await client.upload.uploadFile({
file: file,
resourceType: 'conversation', // 'conversation' | 'workflow' | 'knowledge-base'
onProgress: (percent) => {
console.log(`上传进度: ${percent * 100}%`)
},
})uploadImage(params)
上传图片(自动压缩)。
const url = await client.upload.uploadImage({
file: imageFile,
resourceType: 'conversation',
onProgress: (percent) => console.log(`${percent * 100}%`),
compressOptions: { // 可选
quality: 0.8,
maxWidth: 1920,
},
})类型定义
SimpleMessage
消息类型(与后端保持一致)。
/** 消息内容部件 */
type WorkflowMessagePart =
| { type: 'text'; text: string }
| { type: 'image'; url: string }
| { type: 'video'; url: string }
| { type: 'audio'; url: string }
| { type: 'document'; filename: string; url: string }
interface SimpleMessage {
type: 'human' | 'ai' | 'system' | 'tool'
parts: WorkflowMessagePart[]
id?: string
}SessionState
会话状态类型。
interface SessionState {
threadId?: string
messages: SimpleMessage[]
isExecuting: boolean
currentNodeId?: string
}SendInput
发送消息输入类型。
interface SendInput {
/** 内容部件数组,按顺序传递给 LLM */
parts: ContentPart[]
}
type ContentPart =
| { type: 'text'; text: string }
| {
type: 'image'
file?: File // 图片文件(会自动压缩并上传)
url?: string // 图片 URL(支持 blob URL 和普通 URL)
}
| {
type: 'document'
file?: File // 文档文件(会自动上传)
url?: string // 文档 URL(支持 blob URL 和普通 URL)
filename: string // 文件名
mimeType?: string // MIME 类型
}完整示例
React 聊天组件
import { useState, useEffect, useRef } from 'react'
import Wtfai, { type SimpleMessage, type WorkflowSession } from 'wtfai'
const client = new Wtfai({
baseUrl: 'https://api.example.com',
})
function ChatComponent({ workflowId }: { workflowId: string }) {
const [messages, setMessages] = useState<SimpleMessage[]>([])
const [isExecuting, setIsExecuting] = useState(false)
const [input, setInput] = useState('')
const sessionRef = useRef<WorkflowSession | null>(null)
const streamingIndexRef = useRef<number | null>(null)
useEffect(() => {
return () => {
sessionRef.current?.abort()
}
}, [])
const handleSend = async () => {
if (!input.trim() || isExecuting) return
const session = client.createSession(workflowId)
sessionRef.current = session
session.on('token', ({ content }) => {
setMessages((prev) => {
const next = [...prev]
if (streamingIndexRef.current === null) {
streamingIndexRef.current = next.length
next.push({
type: 'ai',
parts: [{ type: 'text', text: content }],
})
return next
}
const index = streamingIndexRef.current
const msg = next[index]
const firstPart = msg?.parts?.[0]
if (msg && firstPart?.type === 'text') {
const updatedParts = [...msg.parts]
updatedParts[0] = {
...firstPart,
text: firstPart.text + content,
}
next[index] = { ...msg, parts: updatedParts }
}
return next
})
})
session.on('complete', () => {
setIsExecuting(false)
streamingIndexRef.current = null
})
session.on('error', ({ message }) => {
alert(`错误: ${message}`)
setIsExecuting(false)
streamingIndexRef.current = null
})
try {
setIsExecuting(true)
await session.send({
parts: [{ type: 'text', text: input }]
})
setInput('')
} catch (error) {
console.error(error)
setIsExecuting(false)
streamingIndexRef.current = null
}
}
return (
<div>
<div className="messages">
{messages.map((msg, i) => (
<div key={i} className={msg.type}>
{msg.parts.find((part) => part.type === 'text')?.text || '...'}
</div>
))}
{isExecuting && <div>思考中...</div>}
</div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isExecuting}
/>
<button onClick={handleSend} disabled={isExecuting}>
发送
</button>
</div>
)
}注意事项
- 顺序保持:
parts数组中的元素会按顺序传递给 LLM,确保大模型能理解位置指代 - 图片自动压缩:通过 File 上传的图片会自动压缩到 1920px 以内,质量 0.8
- 会话管理:每次调用
send()都会更新threadId,如需保存会话请在start事件中获取 - 错误处理:建议始终监听
error事件处理异常情况 - 资源清理:组件卸载时调用
session.abort()避免内存泄漏
