Appearance
端到端 Demo 应用
一个项目跑通开放平台的全部能力:免登 + 会话、通讯录(成员/团队)、选人、 跳单聊、消息通知、AI(含流式)、数据库只读查询、AI 工具(模型回调你的 服务)、本地能力(用户确认后从其电脑抓网页/无头浏览器提取)、事件订阅 (challenge 验证 + 验签 + 幂等)。
下载
下载完整 Demo 压缩包 ——解压后 npm install && npm start 直接跑,不需要照下面的代码逐段复制粘贴。 压缩包内容跟本页代码同源,下文只是给想先读一遍再决定要不要跑的人看。
bash
unzip jlt-open-platform-demo-latest.zip && cd demo
npm install
npm run preview # 只看界面,模拟数据;或 JLT_APP_KEY=... JLT_APP_SECRET=... npm start 完整联调参考代码
下面是这套 Demo 的核心代码(两个文件即可最小复现):
jlt-demo/
├── server.js # Node 18+,npm i express 后直接跑
└── public/index.html # 前端页面(双端通用;本地能力仅桌面端)1. 注册应用(对号入座)
客户端「组织」→「应用」→「新建应用」:
| 注册项 | 填什么 |
|---|---|
| 页面地址 | http://localhost:3000(本地调试放行 http) |
| 开放能力 | contacts、im、ai、db、ai_tools、local 都勾上(Demo 都会用到);db 需要先绑定一个你有权限的数据库 |
| 事件订阅(应用设置里) | 回调地址 http://localhost:3000/jlt/events,事件全选 |
| AI 工具(应用设置里) | 注册 order_stats 工具:描述「查询订单状态统计(演示)」、参数 {"type":"object","properties":{},"required":[]}、回调 http://localhost:3000/jlt/tools、等待 10 秒 |
bash
npm init -y && npm i express
JLT_BASE=https://<api域名>/api/open \
JLT_APP_KEY=oak_xxx JLT_APP_SECRET=xxx \
node server.js2. server.js
js
// 机灵兔组织应用 Demo 后端:token 缓存 → 免登 → 会话 → 能力代理 → 事件接收。
import crypto from "node:crypto";
import express from "express";
const BASE = process.env.JLT_BASE;
const KEY = process.env.JLT_APP_KEY;
const SECRET = process.env.JLT_APP_SECRET;
// ── app access token 缓存(2h,提前 1 分钟刷新)──
const tokenCache = { token: "", exp: 0 };
async function appToken() {
if (Date.now() < tokenCache.exp) return tokenCache.token;
const r = await fetch(`${BASE}/auth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ app_key: KEY, app_secret: SECRET }),
});
if (!r.ok) throw new Error(`换 app token 失败(${r.status}):${await r.text()}`);
const d = await r.json();
tokenCache.token = d.access_token;
tokenCache.exp = Date.now() + (d.expires_in - 60) * 1000;
return d.access_token;
}
async function jlt(path, opts = {}) {
const r = await fetch(`${BASE}${path}`, {
...opts,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${await appToken()}`,
},
});
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(`${path} ${r.status}:${body.detail ?? ""}`);
return body;
}
const app = express();
app.use(express.json());
app.use(express.static("public"));
// 演示用内存会话;生产换成你自己的会话体系
const sessions = new Map();
const auth = (req, res, next) => {
const user = sessions.get(req.header("x-session-id") ?? "");
if (!user) return res.status(401).json({ detail: "未登录" });
req.user = user;
next();
};
// ① 免登:前端把 URL 上的 jlt_auth_code 传上来
app.post("/api/login", async (req, res, next) => {
try {
const user = await jlt("/auth/user", {
method: "POST",
body: JSON.stringify({ auth_code: String(req.body.auth_code ?? "") }),
});
const sid = crypto.randomUUID();
sessions.set(sid, user);
res.json({ session_id: sid, user });
} catch (e) { next(e); }
});
// ② 通讯录:成员 + 团队
app.get("/api/members", auth, async (req, res, next) => {
try { res.json(await jlt("/org/members?offset=0&limit=100")); } catch (e) { next(e); }
});
app.get("/api/teams", auth, async (req, res, next) => {
try { res.json(await jlt("/org/teams")); } catch (e) { next(e); }
});
// ③ 给成员发工作通知(系统账号代发,带【应用名】前缀)
app.post("/api/notify", auth, async (req, res, next) => {
try {
res.json(await jlt("/im/send", {
method: "POST",
body: JSON.stringify({ user_ids: req.body.user_ids, content: req.body.content }),
}));
} catch (e) { next(e); }
});
// ④ AI:mountChat 的中继契约——POST {messages, stream} → SSE 透传
// (计费走应用所属组织钱包;无状态,组件每次带全量 messages)
app.post("/api/ai", auth, async (req, res, next) => {
try {
const upstream = await fetch(`${BASE}/ai/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${await appToken()}`,
},
body: JSON.stringify({
messages: Array.isArray(req.body.messages) ? req.body.messages : [],
stream: req.body.stream !== false,
}),
});
if (!upstream.ok) {
const t = await upstream.text();
return res.status(502).json({ detail: t.slice(0, 300) });
}
if (req.body.stream !== false) {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("X-Accel-Buffering", "no");
for await (const chunk of upstream.body) res.write(chunk);
return res.end();
}
res.json(await upstream.json());
} catch (e) { next(e); }
});
// ⑤' 数据库只读查询(db 能力;安全校验在平台侧:单条 SELECT/行数上限/超时)
app.post("/api/db-query", auth, async (req, res, next) => {
try {
res.json(await jlt("/db/query", {
method: "POST",
body: JSON.stringify({ db_id: req.body.db_id, sql: req.body.sql, limit: 50 }),
}));
} catch (e) { next(e); }
});
// ⑤'' AI 工具回调:与事件同一套验签;challenge 回显 + tool_call 处理。
// 成员对 AI 说「查一下订单统计」,模型就会调到这里。
app.post("/jlt/tools", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.header("X-Jlt-Timestamp") || "";
const sig = req.header("X-Jlt-Signature") || "";
const expected = crypto
.createHmac("sha256", SECRET)
.update(ts + "." + req.body.toString("utf8"))
.digest("hex");
const sigOk =
sig &&
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!sigOk || Math.abs(Date.now() / 1000 - Number(ts)) > 3600) {
return res.status(401).json({ error: "bad signature" });
}
const call = JSON.parse(req.body.toString("utf8"));
if (call.type === "url_verification") {
return res.json({ challenge: call.challenge }); // 注册工具时的验证
}
console.log("🔧 工具调用:", call.tool, "by", call.user_id, JSON.stringify(call.arguments));
// Demo 直接回假数据;生产环境在这里跑你的真实业务并秒回精简结果。
res.json({
pending: 3, paid: 41, refunded: 2,
note: "演示数据;user_id=" + call.user_id,
});
});
// ⑤ 事件回调:raw body(验签要原始字节)→ 验签 → challenge 回显 → 幂等处理
const seenEventIds = new Set();
app.post("/jlt/events", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.header("X-Jlt-Timestamp") || "";
const sig = req.header("X-Jlt-Signature") || "";
const expected = crypto
.createHmac("sha256", SECRET)
.update(ts + "." + req.body.toString("utf8"))
.digest("hex");
const sigOk =
sig &&
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!sigOk || Math.abs(Date.now() / 1000 - Number(ts)) > 3600) {
return res.status(401).json({ error: "bad signature" });
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "url_verification") {
return res.json({ challenge: event.challenge }); // 配置回调时的验证
}
if (seenEventIds.has(event.event_id)) return res.json({ ok: true }); // 幂等
seenEventIds.add(event.event_id);
console.log("📥 事件:", event.type, JSON.stringify(event.data));
res.json({ ok: true });
});
app.use((err, _req, res, _next) => {
console.error(err);
res.status(502).json({ detail: err.message });
});
app.listen(3000, () => console.log("Demo: http://localhost:3000(回调 /jlt/events)"));3. public/index.html
html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>机灵兔 Demo 应用</title>
<script src="https://<api域名>/api/open/jsapi/jlt.js"></script>
<style>
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 24px auto; padding: 0 16px; }
button { margin: 2px 4px 2px 0; padding: 6px 14px; }
pre { background: #f6f8fa; padding: 10px; border-radius: 8px; overflow: auto; }
li { margin: 4px 0; cursor: pointer; }
</style>
</head>
<body>
<h2 id="hello">正在登录…</h2>
<p><button id="pick">选人(可多选)</button>
<button id="chat">与选中的人单聊</button>
<button id="notify">给选中的人发通知</button></p>
<!-- SDK 提供 AI 对话组件(一行挂载,见 guide/ai-chat) -->
<script src="https://cos-pub.smabbit.com/open-platform/sdk/1.3.0/jlt-sdk.min.js"></script>
<h3>AI 助手</h3>
<div id="chat" style="height: 420px; margin-bottom: 10px"></div>
<p>本地能力(仅桌面端,每次调用需确认):<input id="localUrl" style="width:280px" value="https://example.com" />
<button id="localFetch">本机抓取</button>
<button id="browserExtract">无头浏览器提取</button></p>
<p>数据库:<input id="dbId" style="width:200px" placeholder="绑定的 db_id" />
<button id="dbQuery">只读查询(演示 SQL)</button></p>
<p><button id="toast">轻提示</button>
<button id="confirm">确认框</button>
<button id="setTitle">设置窗口标题</button></p>
<h3>团队成员(点击可切换)</h3><ul id="teams"></ul>
<pre id="out"></pre>
<script>
const $ = (id) => document.getElementById(id);
const log = (x) => { $("out").textContent += (typeof x === "string" ? x : JSON.stringify(x, null, 2)) + "\n"; };
let sid = localStorage.getItem("sid");
let picked = []; // selectUsers 的结果缓存
async function login(code) {
const r = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ auth_code: code }),
}).then((x) => x.json());
if (!r.session_id) throw new Error(r.detail || "登录失败");
sid = r.session_id;
localStorage.setItem("sid", sid);
return r.user;
}
async function api(path, opts = {}) {
return fetch(path, { ...opts, headers: { "Content-Type": "application/json", "x-session-id": sid, ...(opts.headers || {}) } });
}
async function main() {
// ① 免登:URL 上的码用完即清;会话失效则 requestAuthCode 换新码重登
const qs = new URLSearchParams(location.search);
const code = qs.get("jlt_auth_code");
let user = null;
if (code) {
user = await login(code);
history.replaceState(null, "", location.pathname);
} else if (sid) {
const r = await api("/api/members"); // 探测会话
if (r.status === 401 && window.jlt) {
const { auth_code } = await jlt.requestAuthCode();
user = await login(auth_code);
}
}
if (!user) throw new Error("未登录");
// ② JSAPI 上下文 + 标题
if (window.jlt) {
jlt.ready((ctx) => {
$("hello").textContent = `你好,${ctx.user.nickname}(${ctx.platform})`;
});
jlt.setTitle("机灵兔 Demo");
} else {
$("hello").textContent = `你好,${user.nickname}(浏览器模式,JSAPI 不可用)`;
}
// ③ 团队列表
const { teams } = await api("/api/teams").then((x) => x.json());
for (const t of teams) {
const li = document.createElement("li");
li.textContent = `${t.name}(${t.member_count} 人)`;
li.onclick = () => log(t);
$("teams").appendChild(li);
}
}
// ── AI 对话组件:挂载即用(含流式/停止/重试),会话存 IndexedDB ──
// createChatStore 是 SDK 内置的存储助手(配额远大于 localStorage 的 ~5MB,
// 自带 5MB/2000 条预算裁剪、异步写入、异常只警告不打断聊天)。
const chatStore = jltSdk.createChatStore({ key: "demo" });
chatStore.load().then((saved) => {
jltSdk.mountChat($("chat"), {
endpoint: "/api/ai",
headers: { "x-session-id": sid || "" },
title: "Demo AI 助手",
systemPrompt: "你是机灵兔开放平台 Demo 应用的助手,回答简洁。",
initialHistory: saved,
persistMaxChars: 4 * 1024 * 1024,
onHistoryChange: chatStore.save,
});
});
main().catch((e) => { $("hello").textContent = `加载失败:${e.message}`; });
// ── 交互组件 ──
$("pick").onclick = async () => {
const { users } = await jlt.selectUsers({ multiple: true, title: "挑几个人试试" });
picked = users;
log(`选中 ${users.length} 人:` + users.map((u) => u.nickname || u.user_id).join("、"));
};
$("chat").onclick = async () => {
if (!picked[0]) return jlt.toast({ message: "先选人", type: "error" });
await jlt.openChat({ user_id: picked[0].user_id });
};
$("notify").onclick = async () => {
if (!picked.length) return jlt.toast({ message: "先选人", type: "error" });
const r = await api("/api/notify", {
method: "POST",
body: JSON.stringify({ user_ids: picked.map((u) => u.user_id), content: "Demo 应用问候:合作愉快!" }),
}).then((x) => x.json());
log(r);
jlt.toast({ message: "已发送", type: "success" });
};
$("toast").onclick = () => jlt.toast({ message: "这是一条轻提示", type: "info" });
$("confirm").onclick = async () => {
const { ok } = await jlt.confirm({ message: "确定要继续吗?" });
jlt.toast({ message: ok ? "点了确定" : "点了取消", type: ok ? "success" : "info" });
};
$("setTitle").onclick = () => jlt.setTitle("标题已改 " + new Date().toLocaleTimeString());
// ── 本地能力(仅桌面端;未授予 local 能力或用户拒绝都会报错,注意 catch)──
const localUrl = () => $("localUrl").value.trim();
$("localFetch").onclick = async () => {
try {
const r = await jlt.localFetch({ url: localUrl(), max_chars: 3000 });
log(`本机抓取 ${r.url}(${r.length} 字符${r.truncated ? ",已截断" : ""}):\n${r.text.slice(0, 500)}`);
} catch (e) { window.jlt?.toast({ message: "本机抓取失败:" + e.message, type: "error" }); }
};
$("browserExtract").onclick = async () => {
try {
const r = await jlt.browserExtract({ url: localUrl(), max_chars: 3000 });
log(`浏览器提取 ${r.url}(${r.length} 字符${r.truncated ? ",已截断" : ""}):\n${r.text.slice(0, 500)}`);
} catch (e) { window.jlt?.toast({ message: "浏览器提取失败:" + e.message, type: "error" }); }
};
// ── 数据库只读查询(经自己的后端代理开放 API)──
$("dbQuery").onclick = async () => {
const db_id = $("dbId").value.trim();
if (!db_id) { window.jlt?.toast({ message: "先填绑定的 db_id", type: "error" }); return; }
const r = await api("/api/db-query", {
method: "POST",
body: JSON.stringify({ db_id, sql: "SELECT 1 AS ok, now() AS ts" }),
}).then((x) => x.json());
log(r);
};
</script>
</body>
</html>4. 试一遍
打开应用后按这个顺序感受全链路:
- 免登:打开即显示你的昵称,URL 上的免登码已被清掉
选人→与选中的人单聊(客户端跳到 IM)→给选中的人发通知(消息带 【应用名】前缀)- AI 对话:聊天窗逐字流式渲染、可停止/重试,刷新页面历史还在 (localStorage 持久化);后端
/api/ai就是 mountChat 的中继契约 - AI 工具:回到客户端主页对 AI 助手说「查一下订单统计」——模型会调
app_…__order_stats,终端打印🔧 工具调用,AI 拿到假数据继续回答 - 本地能力:填个 URL 点
本机抓取(弹确认框,允许后出正文);再试无头浏览器提取(首次会准备本地运行时,稍慢) - 数据库:把绑定的
db_id填进去点查询(写语句会被平台直接拒绝,可试) - 在客户端里把某成员移出/拉入组织 → 终端看到
📥 事件: org.member.left … - 普通浏览器直接访问
http://localhost:3000:JSAPI 相关按钮报超时/不可用—— 这就是降级路径
5. 常见排错
- 登录 400:免登码过期/已用(刷新过页面)——回到客户端重新打开应用
- 通知/通讯录 403:组织管理员没在应用设置里勾对应能力
- AI 402:应用所属组织未开通钱包
- 工具/事件收不到:先
curl -d '{"type":"url_verification","challenge":"abc"}' http://localhost:3000/jlt/tools自测回显(events 同理) - 本地能力报"未授予":应用设置勾「本地能力」;报浏览器/Node 错误:客户端 「设置 → 环境检查」准备本地运行时,或改用
本机抓取 - 更多排查见 FAQ