Skip to content

端到端 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)
开放能力contactsimaidbai_toolslocal 都勾上(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.js

2. 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. 试一遍

打开应用后按这个顺序感受全链路:

  1. 免登:打开即显示你的昵称,URL 上的免登码已被清掉
  2. 选人与选中的人单聊(客户端跳到 IM)→ 给选中的人发通知(消息带 【应用名】前缀)
  3. AI 对话:聊天窗逐字流式渲染、可停止/重试,刷新页面历史还在 (localStorage 持久化);后端 /api/ai 就是 mountChat 的中继契约
  4. AI 工具:回到客户端主页对 AI 助手说「查一下订单统计」——模型会调 app_…__order_stats,终端打印 🔧 工具调用,AI 拿到假数据继续回答
  5. 本地能力:填个 URL 点 本机抓取(弹确认框,允许后出正文);再试 无头浏览器提取(首次会准备本地运行时,稍慢)
  6. 数据库:把绑定的 db_id 填进去点查询(写语句会被平台直接拒绝,可试)
  7. 在客户端里把某成员移出/拉入组织 → 终端看到 📥 事件: org.member.left …
  8. 普通浏览器直接访问 http://localhost:3000:JSAPI 相关按钮报超时/不可用—— 这就是降级路径

5. 常见排错

  • 登录 400:免登码过期/已用(刷新过页面)——回到客户端重新打开应用
  • 通知/通讯录 403:组织管理员没在应用设置里勾对应能力
  • AI 402:应用所属组织未开通钱包
  • 工具/事件收不到:先 curl -d '{"type":"url_verification","challenge":"abc"}' http://localhost:3000/jlt/tools 自测回显(events 同理)
  • 本地能力报"未授予":应用设置勾「本地能力」;报浏览器/Node 错误:客户端 「设置 → 环境检查」准备本地运行时,或改用 本机抓取
  • 更多排查见 FAQ

机灵兔开放平台 · 组织应用开发者文档