Appearance
端到端 Demo 应用
一个项目跑通开放平台的全部能力:免登 + 会话、通讯录(成员/团队)、选人、 跳单聊/群聊、消息通知、分享给同事、本机文件选择/另存为、AI(含流式)、 数据库只读查询、AI 工具(模型回调你的服务)、本地能力(用户确认后 从其电脑抓网页/无头浏览器提取)、完整 AI Agent(jlt.agent,把对话交给 客户端内置 Agent 执行)、隔离浏览器会话(browserOpen…browserClose, 多步爬取/自动化)、事件订阅(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 的核心代码(两个文件即可最小复现选人/单聊/通知/AI 对话/数据库 查询/AI 工具/本地能力这条主链路)。完整 AI 能力(jlt.agent)与隔离浏览器 会话(browserOpen…)用到的多视图工作台代码更长,只在下载的压缩包里,本页 不逐行展开——用法参见 JSAPI 参考,试用步骤见下方「4. 试一遍」。
jlt-demo/
├── server.js # Node 18+,npm i express 后直接跑
└── public/index.html # 前端页面(双端通用;本地能力仅桌面端)1. 注册应用(对号入座)
客户端「组织」→「应用」→「新建应用」:
| 注册项 | 填什么 |
|---|---|
| 页面地址 | http://localhost:3131(本地调试放行 http) |
| 开放能力 | contacts、im、ai、db、ai_tools、local 都勾上(Demo 都会用到);db 需要先绑定一个你有权限的数据库 |
| 事件订阅(应用设置里) | 回调地址 http://localhost:3131/jlt/events,事件全选 |
| AI 工具(应用设置里) | 注册 order_stats 工具:描述「查询订单状态统计(演示)」、参数 {"type":"object","properties":{},"required":[]}、回调 http://localhost:3131/jlt/tools、等待 10 秒 |
回调要能被平台服务器打到:demo 默认只监听
127.0.0.1,本地联调请HOST=0.0.0.0 node server.js;平台的回调 SSRF 防线默认拒绝内网/环回目标, 自建服务端联调时需设置ORG_APP_CALLBACK_ALLOW_PRIVATE_IP=true(见 事件订阅)。
bash
npm init -y && npm i express
JLT_BASE=https://<api域名>/api/open \
JLT_APP_KEY=oak_xxx JLT_APP_SECRET=xxx \
HOST=0.0.0.0 node server.js2. server.js
js
// 机灵兔组织应用全能力 Demo 后端。
// 完整联调:JLT_BASE/JLT_APP_KEY/JLT_APP_SECRET 环境变量 + node server.js
// 纯界面预览:node server.js --preview;端口:PORT 环境变量,默认 3131;
// 监听地址:HOST 环境变量,默认 127.0.0.1——完整联调模式下本进程持有真实
// app 凭证做中继,默认只绑回环;平台回调需要打到本机时请用隧道,或显式
// HOST=0.0.0.0 自担风险。
import crypto from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
const IS_MAIN = Boolean(process.argv[1]) && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
const BASE = (process.env.JLT_BASE || "http://localhost:8000/api/open").replace(/\/$/, "");
const PREVIEW_ONLY = process.argv.includes("--preview");
const KEY = process.env.JLT_APP_KEY || (PREVIEW_ONLY ? "preview-key" : "");
const SECRET = process.env.JLT_APP_SECRET || (PREVIEW_ONLY ? "preview-secret" : "");
const PORT = Number(process.env.PORT || 3131);
if (IS_MAIN && (!KEY || !SECRET)) {
console.error(
"缺少 JLT_APP_KEY / JLT_APP_SECRET 环境变量——服务没有启动,客户端里打开应用会一直转圈。\n" +
" 完整联调:JLT_BASE=… JLT_APP_KEY=oak_xxx JLT_APP_SECRET=xxx npm start(凭证在客户端「组织→应用→设置」里复制)\n" +
" 只看界面:npm run preview",
);
process.exit(1);
}
class JltApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// ── 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 JltApiError(r.status, `换 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 tokenCache.token;
}
async function jlt(path, opts = {}, allowTokenRefresh = true) {
const r = await fetch(`${BASE}${path}`, {
...opts,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${await appToken()}`,
},
});
if (r.status === 401 && allowTokenRefresh && tokenCache.token) {
tokenCache.token = "";
tokenCache.exp = 0;
return jlt(path, opts, false);
}
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new JltApiError(r.status, `${path} ${r.status}:${body.detail ?? ""}`);
return body;
}
const activityFeed = [];
function recordActivity(kind, message, detail = "", level = "info", visibility = {}) {
activityFeed.unshift({
id: crypto.randomUUID(),
at: new Date().toISOString(),
kind,
message,
detail: detail || message,
level,
userId: visibility.userId || "",
adminOnly: visibility.adminOnly === true,
});
activityFeed.splice(100);
}
function validCallbackSignature(req, secret = SECRET) {
const timestamp = req.header("X-Jlt-Timestamp") || "";
const signature = req.header("X-Jlt-Signature") || "";
if (!Buffer.isBuffer(req.rawBody) || !/^[a-f0-9]{64}$/i.test(signature) || !/^[0-9]+$/.test(timestamp)) return false;
if (!secret) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${req.rawBody.toString("utf8")}`).digest();
const actual = Buffer.from(signature, "hex");
// 时间窗 5 分钟:平台投递近实时,5 分钟绰绰有余;留 1 小时等于允许截获的
// 合法签名回调被重放一小时。
return actual.length === expected.length
&& crypto.timingSafeEqual(actual, expected)
&& Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
}
const app = express();
// 事件/工具回调验签必须使用请求的原始字节。若先让 express.json() 丢掉原文,
// JSON 空白或键顺序稍有变化就会导致签名无法复算。
app.use(express.json({
verify(req, _res, body) {
if (req.path === "/jlt/events" || req.path === "/jlt/tools") {
req.rawBody = Buffer.from(body);
}
},
}));
// 页面始终从同源加载 JSAPI,避免把 localhost/生产域名写死进可分发 Demo。
// Preview 不连接平台,返回空脚本让页面自然进入模拟数据模式。
// 拉取成功后内存缓存 5 分钟:script 标签加载失败会让整页退化成「浏览器模式」,
// 这里多抗一层上游抖动;带上 TTL 是为了 ai-service 升级 jlt.js 版本后最多
// 5 分钟就能滚动到新协议,不会把旧版本钉死在进程生命周期里。
let cachedJsapi = "";
let cachedJsapiAt = 0;
const JSAPI_CACHE_MS = 5 * 60 * 1000;
app.get("/jlt.js", async (_req, res, next) => {
if (PREVIEW_ONLY) {
res.type("application/javascript").send("/* preview: no desktop bridge */");
return;
}
if (cachedJsapi && Date.now() - cachedJsapiAt < JSAPI_CACHE_MS) {
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
res.setHeader("Cache-Control", "public, max-age=300");
res.send(cachedJsapi);
return;
}
try {
const upstream = await fetch(`${BASE}/jsapi/jlt.js`);
if (!upstream.ok) throw new JltApiError(upstream.status, `加载 JSAPI 失败(${upstream.status})——页面将退化成浏览器模式,请检查 JLT_BASE(当前:${BASE})`);
cachedJsapi = await upstream.text();
cachedJsapiAt = Date.now();
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
res.setHeader("Cache-Control", "public, max-age=300");
res.send(cachedJsapi);
} catch (error) {
// 上游临时不可用时退回过期缓存,比直接 500(页面退化成浏览器模式)好。
if (cachedJsapi) {
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.send(cachedJsapi);
return;
}
next(error);
}
});
// 静态目录锚定到本文件旁边,而不是进程 cwd——从别的目录 node server.js 时
// 才不会把那个目录的 public/ 当成 demo 资源发出去。
app.use(express.static(path.join(path.dirname(fileURLToPath(import.meta.url)), "public")));
// 演示用 8 小时内存会话;生产换成服务端会话存储与 HttpOnly Cookie。
const sessions = new Map();
const auth = (req, res, next) => {
const sessionId = req.header("x-session-id") ?? "";
const session = sessions.get(sessionId);
if (!session || session.expiresAt <= Date.now()) {
if (session) sessions.delete(sessionId);
return res.status(401).json({ detail: "未登录或会话已过期" });
}
req.user = session.user;
next();
};
// /api/login 是唯一免认证入口:每个请求都会让本进程用真实 app 凭证打一次上游
// /auth/user——给它一个轻量每 IP 限流,别让本服务变成消耗平台配额的放大器。
const loginAttempts = new Map(); // ip -> 最近 1 分钟的时间戳
function loginRateLimited(ip) {
const now = Date.now();
const recent = (loginAttempts.get(ip) || []).filter((t) => now - t < 60_000);
loginAttempts.set(ip, recent);
if (recent.length >= 10) return true;
recent.push(now);
return false;
}
// 会话/限流桶都是只增不清的 Map,靠这个定时器回收,长跑进程才不会缓慢膨胀。
setInterval(() => {
const now = Date.now();
for (const [sid, s] of sessions) if (s.expiresAt <= now) sessions.delete(sid);
for (const [ip, attempts] of loginAttempts) if (!attempts.some((t) => now - t < 60_000)) loginAttempts.delete(ip);
}, 10 * 60 * 1000).unref();
// ① 免登:前端把 URL 上的 jlt_auth_code 传上来
app.post("/api/login", async (req, res, next) => {
try {
const ip = req.socket.remoteAddress || "?";
if (loginRateLimited(ip)) return res.status(429).json({ detail: "尝试过于频繁,请稍后再试" });
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, expiresAt: Date.now() + 8 * 60 * 60 * 1000 });
recordActivity("AUTH", "组织成员完成免登", `${user.nickname || user.user_id} 已进入 NOVA`, "success", { userId: user.user_id });
res.json({ session_id: sid, user });
} catch (e) { next(e); }
});
app.get("/api/session", auth, (req, res) => {
res.json({ user: req.user });
});
// ② 通讯录:成员 + 团队 + 团队成员
app.get("/api/members", auth, async (req, res, next) => {
try {
const members = [];
let orgId = "";
for (let offset = 0; offset < 2000; offset += 100) {
const page = await jlt(`/org/members?offset=${offset}&limit=100`);
orgId ||= page.org_id || "";
members.push(...(page.members || []));
if (!page.members || page.members.length < 100) break;
}
res.json({ org_id: orgId, members });
} 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.get("/api/teams/:teamId/members", auth, async (req, res, next) => {
try {
res.json(await jlt(`/org/teams/${encodeURIComponent(req.params.teamId)}/members`));
} catch (e) { next(e); }
});
// ③ 给成员发工作通知(系统账号代发,带【应用名】前缀)
app.post("/api/notify", auth, async (req, res, next) => {
try {
const result = await jlt("/im/send", {
method: "POST",
body: JSON.stringify({ user_ids: req.body.user_ids, content: req.body.content }),
});
const success = (result.results || []).filter((item) => item.ok).length;
recordActivity("NOTIFY", "应用通知已发送", `${success}/${result.results?.length || 0} 位成员送达`, "success", { userId: req.user.user_id });
res.json(result);
} catch (e) { next(e); }
});
// ③' 模型与知识库:展示真实可用模型,并在应用绑定知识库内检索
app.get("/api/models", auth, async (_req, res, next) => {
try { res.json(await jlt("/ai/models")); } catch (e) { next(e); }
});
app.post("/api/kb-search", auth, async (req, res, next) => {
try {
const result = await jlt("/kb/search", {
method: "POST",
body: JSON.stringify({ query: req.body.query, kb_id: req.body.kb_id || "", max_hits: req.body.max_hits || 20 }),
});
recordActivity("KNOWLEDGE", "组织知识检索完成", `${result.hits?.length || 0} 条命中`, "success", { userId: req.user.user_id });
res.json(result);
} catch (e) { next(e); }
});
// ④ AI:mountChat 的中继契约——POST {messages, stream} → SSE 透传
app.post("/api/ai", auth, async (req, res, next) => {
const streaming = req.body.stream !== false;
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 : [],
model: req.body.model || "auto",
stream: streaming,
}),
// 流式一轮 Agent 可能跑几分钟:10 分钟总上限;非流式 3 分钟必回。
signal: AbortSignal.timeout(streaming ? 10 * 60 * 1000 : 3 * 60 * 1000),
});
if (!upstream.ok) {
const t = await upstream.text();
return res.status(502).json({ detail: t.slice(0, 300) });
}
if (streaming) {
recordActivity("AI", "流式 AI 会话已启动", `模型:${req.body.model || "auto"}`, "success", { userId: req.user.user_id });
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
// 客户端断开 → 联动取消上游,别让本进程继续白拉流烧上游 token。
req.on("close", () => { upstream.body.cancel().catch(() => {}); });
try {
for await (const chunk of upstream.body) {
if (!res.write(chunk)) await new Promise((resolve) => res.once("drain", resolve));
}
} catch {
// 上游中途断开/超时:响应头已发出,走不了错误中间件,只能干净收尾。
}
return res.end();
}
const result = await upstream.json();
recordActivity("AI", "AI 会话已完成", `模型:${result.model || req.body.model || "auto"}`, "success", { userId: req.user.user_id });
res.json(result);
} catch (e) { next(e); }
});
// ⑤ 数据库发现、结构查看与只读查询(安全校验在平台侧)
app.get("/api/databases", auth, async (_req, res, next) => {
try { res.json(await jlt("/db/databases")); } catch (e) { next(e); }
});
app.get("/api/databases/:dbId/tables", auth, async (req, res, next) => {
try { res.json(await jlt(`/db/${encodeURIComponent(req.params.dbId)}/tables`)); } catch (e) { next(e); }
});
app.get("/api/databases/:dbId/tables/:table", auth, async (req, res, next) => {
try {
const query = new URLSearchParams();
if (req.query.schema) query.set("schema", String(req.query.schema));
const queryString = query.toString();
const suffix = queryString ? `?${queryString}` : "";
res.json(await jlt(`/db/${encodeURIComponent(req.params.dbId)}/tables/${encodeURIComponent(req.params.table)}${suffix}`));
} catch (e) { next(e); }
});
app.post("/api/db-query", auth, async (req, res, next) => {
try {
const result = await jlt("/db/query", {
method: "POST",
body: JSON.stringify({ db_id: req.body.db_id, sql: req.body.sql, limit: 50 }),
});
recordActivity("QUERY", "只读查询已执行", `${result.row_count || 0} 行`, result.ok === false ? "error" : "success", { userId: req.user.user_id });
res.json(result);
} catch (e) { next(e); }
});
// ⑥ AI 工具清单 + 当前 Demo 进程收到的事件流
app.get("/api/tools", auth, async (_req, res, next) => {
try { res.json(await jlt("/tools")); } catch (e) { next(e); }
});
app.get("/api/activity", auth, (req, res) => {
const isAdmin = req.user.org_role === "owner" || req.user.org_role === "admin";
const events = activityFeed
.filter((event) => isAdmin || (!event.adminOnly && (!event.userId || event.userId === req.user.user_id)))
.slice(0, 50)
.map(({ userId: _userId, adminOnly: _adminOnly, ...event }) => event);
res.json({ events });
});
// 幂等去重(事件与工具回调共用,按 event_id / call_id):内存 Set 只是 Demo 级
// 实现——生产请把 id 落持久化幂等表,重启后仍要去重(平台失败会重试)。
const seenCallbackIds = new Set();
function seenOnce(id) {
if (typeof id !== "string" || !id) return false;
if (seenCallbackIds.has(id)) return true;
seenCallbackIds.add(id);
if (seenCallbackIds.size > 1000) seenCallbackIds.delete(seenCallbackIds.values().next().value);
return false;
}
// ⑤'' AI 工具回调:与事件同一套验签;challenge 回显 + tool_call 处理。
// 成员对 AI 说「查一下订单统计」,模型就会调到这里。
app.post("/jlt/tools", (req, res) => {
if (!Buffer.isBuffer(req.rawBody)) return res.status(400).json({ error: "missing raw body" });
if (!validCallbackSignature(req)) {
return res.status(401).json({ error: "bad signature" });
}
const call = req.body;
if (call.type === "url_verification") {
recordActivity("TOOL", "AI 工具回调地址验证通过", "challenge 已安全回显", "success");
return res.json({ challenge: call.challenge }); // 注册工具时的验证
}
if (seenOnce(call.call_id)) return res.json({ ok: true }); // 重试/重放的去重
console.log("🔧 工具调用:", call.tool, "by", call.user_id, JSON.stringify(call.arguments));
if (call.context) console.log(" 上下文:", JSON.stringify(call.context).slice(0, 200));
recordActivity("TOOL", `模型调用 ${call.tool || "unnamed_tool"}`, `参数:${JSON.stringify(call.arguments || {}).slice(0, 300)}`, "success", { userId: call.user_id || "" });
// Demo 直接回假数据;生产环境在这里跑你的真实业务并秒回精简结果。
res.json({
pending: 3, paid: 41, refunded: 2,
note: "演示数据;user_id=" + call.user_id,
});
});
// ⑤ 事件回调:raw body(验签要原始字节)→ 验签 → challenge 回显 → 幂等处理
app.post("/jlt/events", (req, res) => {
if (!Buffer.isBuffer(req.rawBody)) return res.status(400).json({ error: "missing raw body" });
if (!validCallbackSignature(req)) {
return res.status(401).json({ error: "bad signature" });
}
const event = req.body;
if (event.type === "url_verification") {
recordActivity("WEBHOOK", "事件回调地址验证通过", "challenge 已安全回显", "success");
return res.json({ challenge: event.challenge }); // 配置回调时的验证
}
if (seenOnce(event.event_id)) return res.json({ ok: true }); // 幂等
console.log("📥 事件:", event.type, JSON.stringify(event.data));
recordActivity("WEBHOOK", event.type || "unknown.event", JSON.stringify(event.data || {}).slice(0, 500), "success", { adminOnly: true });
res.json({ ok: true });
});
app.use((err, _req, res, _next) => {
console.error(err);
const status = Number.isInteger(err.status) && err.status >= 400 && err.status < 600 ? err.status : 502;
// SSE 等已经发过响应头的请求没法再改状态码,只能收尾——否则 res.status 会抛
// ERR_HTTP_HEADERS_SENT,客户端拿到一条没有收尾的断流。
if (res.headersSent) return res.end();
res.status(status).json({ detail: err.message });
});
recordActivity("SYSTEM", "NOVA Demo 服务已启动", PREVIEW_ONLY ? "界面预览模式" : `开放平台:${BASE}`, "success");
if (IS_MAIN) {
// 默认只绑回环:完整联调模式下本进程持有真实 app 凭证,绑 0.0.0.0 等于把
// 中继开放给整个局域网。需要外部可达(如平台回调)时显式设 HOST。
const HOST = process.env.HOST || "127.0.0.1";
app.listen(PORT, HOST, () => console.log(`Demo: http://${HOST === "0.0.0.0" ? "localhost" : HOST}:${PORT}${PREVIEW_ONLY ? "(界面预览模式)" : "(回调 /jlt/events、/jlt/tools)"}`));
}
export { app, validCallbackSignature };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/2.0.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 hash = new URLSearchParams(location.hash.replace(/^#/, ""));
const qs = new URLSearchParams(location.search);
const code = hash.get("jlt_auth_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填进去点查询(写语句会被平台直接拒绝,可试) - 完整 AI 能力:切到「智能协作终端」跟 Agent 对话——工具调用、本机执行、 生成文件都会实时出卡片;每轮开始前桌面壳会弹窗确认(需应用同时被授予
ai+local);普通浏览器打开会自动降级为平台云端流式对话。终端还演示了 SDK 2.0 组件的三个开关:左侧多会话(IndexedDB 本地存档,可新建/切换/ 删除)、THEME(组件深浅色即时切换)、LANG(中英文案)——都在展示mountChat的能力,见 AI 对话界面 - 隔离浏览器会话:「本机能力」面板里开一个会话——快照拿 ref → 点击/ 输入 → 截图 → 关闭;跳到未确认的新域名时会先弹窗确认(
need_consent) - 本机文件与分享(v1.6.0):回到「通讯录与团队」面板,点
选择本机文件会弹出真实的系统文件选择框(不需要任何 scope);另存为会导出一段文本 到你选的位置;分享给同事会弹选人组件、选中后以你自己的身份发一条消息 (需要应用被授予im能力);填一个真实的group_id(比如某个团队的 绑定群 id)点打开群聊会跳到该群聊——这四个能力仅桌面端 - 在客户端里把某成员移出/拉入组织 → 终端看到
📥 事件: org.member.left … - 普通浏览器直接访问
http://localhost:3131:JSAPI 相关按钮报超时/不可用—— 这就是降级路径
5. 常见排错
- 登录 400:免登码过期/已用(刷新过页面)——回到客户端重新打开应用
- 通知/通讯录 403:组织管理员没在应用设置里勾对应能力
- AI 402:应用所属组织未开通钱包
- 工具/事件收不到:先
curl -d '{"type":"url_verification","challenge":"abc"}' http://localhost:3131/jlt/tools自测回显(events 同理) - 本地能力报"未授予":应用设置勾「本地能力」;报浏览器/Node 错误:客户端 「设置 → 环境检查」准备本地运行时,或改用
本机抓取 - Agent/浏览器会话报"未授予"或会话相关错误:见 FAQ · AI Agent 与隔离浏览器会话相关
- 更多排查见 FAQ