wtfai
v1.5.5
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({ content: '你好' })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 }) => {
// 发生错误
})
最佳实践(推荐组合)
- 打字机效果:使用
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
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()避免内存泄漏
