@tidblink/server 0.3.0-rc.0 → 0.4.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +557 -368
- package/dist/audio.d.ts +9 -0
- package/dist/audio.d.ts.map +1 -1
- package/dist/audio.js +6 -1
- package/dist/audio.js.map +1 -1
- package/dist/backend-connection.d.ts +89 -0
- package/dist/backend-connection.d.ts.map +1 -0
- package/dist/backend-connection.js +300 -0
- package/dist/backend-connection.js.map +1 -0
- package/dist/backend-pool.d.ts +93 -0
- package/dist/backend-pool.d.ts.map +1 -0
- package/dist/backend-pool.js +419 -0
- package/dist/backend-pool.js.map +1 -0
- package/dist/client-tool-executor.d.ts +31 -0
- package/dist/client-tool-executor.d.ts.map +1 -0
- package/dist/client-tool-executor.js +190 -0
- package/dist/client-tool-executor.js.map +1 -0
- package/dist/client-tools.d.ts +0 -25
- package/dist/client-tools.d.ts.map +1 -1
- package/dist/client-tools.js +1 -67
- package/dist/client-tools.js.map +1 -1
- package/dist/connector-callback.d.ts +92 -0
- package/dist/connector-callback.d.ts.map +1 -0
- package/dist/connector-callback.js +155 -0
- package/dist/connector-callback.js.map +1 -0
- package/dist/connectors.d.ts +66 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +71 -0
- package/dist/connectors.js.map +1 -0
- package/dist/control.js +1 -1
- package/dist/control.js.map +1 -1
- package/dist/errors.d.ts +34 -11
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +36 -8
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +158 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +110 -0
- package/dist/events.js.map +1 -0
- package/dist/files.js +9 -9
- package/dist/files.js.map +1 -1
- package/dist/history.d.ts +38 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +1 -1
- package/dist/history.js.map +1 -1
- package/dist/http.d.ts +28 -4
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +44 -24
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +26 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/organization.d.ts +45 -28
- package/dist/organization.d.ts.map +1 -1
- package/dist/organization.js +67 -73
- package/dist/organization.js.map +1 -1
- package/dist/realtime.d.ts +0 -1
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +0 -1
- package/dist/realtime.js.map +1 -1
- package/dist/session-connection.d.ts +144 -0
- package/dist/session-connection.d.ts.map +1 -0
- package/dist/session-connection.js +334 -0
- package/dist/session-connection.js.map +1 -0
- package/dist/session-send.d.ts +49 -0
- package/dist/session-send.d.ts.map +1 -0
- package/dist/session-send.js +179 -0
- package/dist/session-send.js.map +1 -0
- package/dist/session.d.ts +37 -8
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +56 -29
- package/dist/session.js.map +1 -1
- package/dist/skills.d.ts +103 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +98 -0
- package/dist/skills.js.map +1 -0
- package/dist/sse.d.ts +5 -2
- package/dist/sse.d.ts.map +1 -1
- package/dist/sse.js +7 -2
- package/dist/sse.js.map +1 -1
- package/dist/template-switch.d.ts +51 -0
- package/dist/template-switch.d.ts.map +1 -0
- package/dist/template-switch.js +81 -0
- package/dist/template-switch.js.map +1 -0
- package/dist/templates.d.ts +129 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +92 -0
- package/dist/templates.js.map +1 -0
- package/dist/turn.d.ts +4 -0
- package/dist/turn.d.ts.map +1 -1
- package/dist/turn.js +17 -13
- package/dist/turn.js.map +1 -1
- package/dist/usage.d.ts +13 -197
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +5 -3
- package/dist/usage.js.map +1 -1
- package/dist/user.d.ts +10 -51
- package/dist/user.d.ts.map +1 -1
- package/dist/user.js +18 -105
- package/dist/user.js.map +1 -1
- package/examples/control-turn.mjs +9 -4
- package/examples/create-session.mjs +37 -24
- package/examples/http-connector.mjs +127 -0
- package/examples/lamp-backend.mjs +175 -0
- package/examples/prepare-realtime-connection.mjs +18 -7
- package/examples/read-usage.mjs +7 -25
- package/examples/send-message.mjs +9 -4
- package/examples/submit-audio.mjs +9 -4
- package/examples/upload-file.mjs +9 -4
- package/package.json +7 -13
- package/dist/progress.d.ts +0 -34
- package/dist/progress.d.ts.map +0 -1
- package/dist/progress.js +0 -154
- package/dist/progress.js.map +0 -1
- package/dist/schedulers.d.ts +0 -129
- package/dist/schedulers.d.ts.map +0 -1
- package/dist/schedulers.js +0 -91
- package/dist/schedulers.js.map +0 -1
- package/examples/answer-client-tool.mjs +0 -69
- package/examples/observe-message.mjs +0 -44
- package/examples/organization-session.mjs +0 -66
- package/examples/provision-user.mjs +0 -83
- package/examples/schedule-task.mjs +0 -67
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { pathToFileURL } from "node:url";
|
|
2
|
-
import {
|
|
2
|
+
import { OrganizationClient } from "@tidblink/server";
|
|
3
3
|
|
|
4
4
|
/** Persist the action and both identities before dispatch; recovery reads that command. */
|
|
5
5
|
export async function controlTurnWorkflow({
|
|
6
6
|
baseUrl,
|
|
7
|
-
|
|
7
|
+
organizationApiKey,
|
|
8
|
+
userId,
|
|
8
9
|
sessionId,
|
|
9
10
|
turnId,
|
|
10
11
|
clientEventId,
|
|
@@ -13,7 +14,10 @@ export async function controlTurnWorkflow({
|
|
|
13
14
|
}) {
|
|
14
15
|
if (action !== "cancel" && action !== "retry")
|
|
15
16
|
throw new TypeError("action must be cancel or retry");
|
|
16
|
-
const session = new
|
|
17
|
+
const session = new OrganizationClient({
|
|
18
|
+
baseUrl,
|
|
19
|
+
apiKey: organizationApiKey,
|
|
20
|
+
}).user(userId).session(
|
|
17
21
|
sessionId,
|
|
18
22
|
);
|
|
19
23
|
const input = { turnId, clientEventId, ...(signal ? { signal } : {}) };
|
|
@@ -32,7 +36,8 @@ if (
|
|
|
32
36
|
try {
|
|
33
37
|
const result = await controlTurnWorkflow({
|
|
34
38
|
baseUrl: process.env.AGENT_SERVICE_URL,
|
|
35
|
-
|
|
39
|
+
organizationApiKey: process.env.ORGANIZATION_API_KEY,
|
|
40
|
+
userId: process.env.END_USER_ID,
|
|
36
41
|
sessionId: process.env.SESSION_ID,
|
|
37
42
|
turnId: process.env.TURN_ID,
|
|
38
43
|
clientEventId: process.env.CLIENT_EVENT_ID,
|
|
@@ -1,32 +1,43 @@
|
|
|
1
1
|
import { pathToFileURL } from "node:url";
|
|
2
|
-
import {
|
|
2
|
+
import { OrganizationClient } from "@tidblink/server";
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Retain the owner, selected Template, optional Session options and the
|
|
6
|
+
* creation key before calling. Pass `acknowledgeSessionRecall: true` when the
|
|
7
|
+
* Template enables cross-Session Recall.
|
|
8
|
+
*/
|
|
5
9
|
export async function createSessionWorkflow({
|
|
6
10
|
baseUrl,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
organizationApiKey,
|
|
12
|
+
userId,
|
|
13
|
+
templateId,
|
|
10
14
|
sessionCreationKey,
|
|
11
15
|
model,
|
|
16
|
+
additionalInstructions,
|
|
17
|
+
sandbox,
|
|
18
|
+
acknowledgeSessionRecall,
|
|
12
19
|
}) {
|
|
13
|
-
if (!
|
|
20
|
+
if (!templateId)
|
|
14
21
|
throw new TypeError(
|
|
15
|
-
"Select and retain
|
|
22
|
+
"Select and retain a templateId before creating a Session",
|
|
16
23
|
);
|
|
17
|
-
const
|
|
24
|
+
const organization = new OrganizationClient({
|
|
25
|
+
baseUrl,
|
|
26
|
+
apiKey: organizationApiKey,
|
|
27
|
+
});
|
|
28
|
+
const user = organization.user(userId);
|
|
18
29
|
const templates = await user.listAgentTemplates();
|
|
19
30
|
const models = await user.listModels();
|
|
20
|
-
const agent = await user.createAgent({
|
|
21
|
-
agentTemplateId,
|
|
22
|
-
idempotencyKey: agentCreationKey,
|
|
23
|
-
});
|
|
24
31
|
// Omission is part of the creation identity; resolving today's default would change a replay.
|
|
25
|
-
const
|
|
32
|
+
const optional = { model, additionalInstructions, sandbox, acknowledgeSessionRecall };
|
|
33
|
+
const session = await user.createSession({
|
|
34
|
+
templateId,
|
|
26
35
|
idempotencyKey: sessionCreationKey,
|
|
27
|
-
...(
|
|
36
|
+
...Object.fromEntries(
|
|
37
|
+
Object.entries(optional).filter(([, value]) => value !== undefined),
|
|
38
|
+
),
|
|
28
39
|
});
|
|
29
|
-
return {
|
|
40
|
+
return { organization, user, session, templates, models };
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
if (
|
|
@@ -35,33 +46,35 @@ if (
|
|
|
35
46
|
) {
|
|
36
47
|
const {
|
|
37
48
|
AGENT_SERVICE_URL,
|
|
38
|
-
|
|
49
|
+
ORGANIZATION_API_KEY,
|
|
50
|
+
END_USER_ID,
|
|
39
51
|
TEMPLATE_ID,
|
|
40
|
-
AGENT_CREATION_KEY,
|
|
41
52
|
SESSION_CREATION_KEY,
|
|
42
53
|
MODEL_ID,
|
|
54
|
+
ACKNOWLEDGE_SESSION_RECALL,
|
|
43
55
|
} = process.env;
|
|
44
56
|
if (
|
|
45
57
|
!AGENT_SERVICE_URL ||
|
|
46
|
-
!
|
|
58
|
+
!ORGANIZATION_API_KEY ||
|
|
59
|
+
!END_USER_ID ||
|
|
47
60
|
!TEMPLATE_ID ||
|
|
48
|
-
!AGENT_CREATION_KEY ||
|
|
49
61
|
!SESSION_CREATION_KEY
|
|
50
62
|
)
|
|
51
63
|
throw new Error(
|
|
52
|
-
"Set AGENT_SERVICE_URL,
|
|
64
|
+
"Set AGENT_SERVICE_URL, ORGANIZATION_API_KEY, END_USER_ID, TEMPLATE_ID and SESSION_CREATION_KEY; MODEL_ID and ACKNOWLEDGE_SESSION_RECALL=true are optional",
|
|
53
65
|
);
|
|
54
66
|
const result = await createSessionWorkflow({
|
|
55
67
|
baseUrl: AGENT_SERVICE_URL,
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
organizationApiKey: ORGANIZATION_API_KEY,
|
|
69
|
+
userId: END_USER_ID,
|
|
58
70
|
sessionCreationKey: SESSION_CREATION_KEY,
|
|
59
|
-
|
|
71
|
+
templateId: TEMPLATE_ID,
|
|
60
72
|
model: MODEL_ID,
|
|
73
|
+
acknowledgeSessionRecall:
|
|
74
|
+
ACKNOWLEDGE_SESSION_RECALL === "true" ? true : undefined,
|
|
61
75
|
});
|
|
62
76
|
console.log(
|
|
63
77
|
JSON.stringify({
|
|
64
|
-
agentId: result.agent.id,
|
|
65
78
|
sessionId: result.session.id,
|
|
66
79
|
model: result.session.model,
|
|
67
80
|
}),
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import {
|
|
4
|
+
ConnectorAuthError,
|
|
5
|
+
ConnectorKeySet,
|
|
6
|
+
readConnectorCallContext,
|
|
7
|
+
verifyConnectorCallback,
|
|
8
|
+
} from "@tidblink/server";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A minimal HTTP Connector: an MCP server over Streamable HTTP with plain
|
|
12
|
+
* JSON responses, without an MCP library. Every request must carry a
|
|
13
|
+
* platform-signed token for this Organization and Connector; every tool call
|
|
14
|
+
* carries the Call Context naming the End User it is for.
|
|
15
|
+
*
|
|
16
|
+
* `tools` maps a tool name to `{ description, inputSchema, run(args, context) }`.
|
|
17
|
+
* `run` returns text; deduplicate its side effects by `context.callId`.
|
|
18
|
+
* Agents see each tool as `mcp_<name>__<tool>`, both parts lowercased with
|
|
19
|
+
* other characters replaced by `_`.
|
|
20
|
+
*/
|
|
21
|
+
export function createConnectorHandler({
|
|
22
|
+
baseUrl,
|
|
23
|
+
organizationId,
|
|
24
|
+
connectorId,
|
|
25
|
+
tools,
|
|
26
|
+
name = "example-connector",
|
|
27
|
+
keys = new ConnectorKeySet({ baseUrl }),
|
|
28
|
+
}) {
|
|
29
|
+
const catalog = Object.entries(tools).map(([name, tool]) => ({
|
|
30
|
+
name,
|
|
31
|
+
description: tool.description,
|
|
32
|
+
inputSchema: tool.inputSchema,
|
|
33
|
+
}));
|
|
34
|
+
const answer = (response, status, body) => {
|
|
35
|
+
response.writeHead(status, body ? { "content-type": "application/json" } : {});
|
|
36
|
+
response.end(body ? JSON.stringify(body) : undefined);
|
|
37
|
+
};
|
|
38
|
+
return async (request, response) => {
|
|
39
|
+
try {
|
|
40
|
+
await verifyConnectorCallback(request.headers.authorization, {
|
|
41
|
+
keys,
|
|
42
|
+
organizationId,
|
|
43
|
+
connectorId,
|
|
44
|
+
});
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error instanceof ConnectorAuthError) return answer(response, 401);
|
|
47
|
+
return answer(response, 503);
|
|
48
|
+
}
|
|
49
|
+
if (request.method === "DELETE") return answer(response, 200);
|
|
50
|
+
if (request.method !== "POST") return answer(response, 405);
|
|
51
|
+
let message;
|
|
52
|
+
try {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
55
|
+
message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
56
|
+
} catch {
|
|
57
|
+
return answer(response, 400, {
|
|
58
|
+
jsonrpc: "2.0",
|
|
59
|
+
id: null,
|
|
60
|
+
error: { code: -32700, message: "Parse error" },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// Notifications such as notifications/initialized need no answer.
|
|
64
|
+
if (message.id === undefined) return answer(response, 202);
|
|
65
|
+
const reply = (result) => answer(response, 200, { jsonrpc: "2.0", id: message.id, result });
|
|
66
|
+
const fail = (code, text) =>
|
|
67
|
+
answer(response, 200, { jsonrpc: "2.0", id: message.id, error: { code, message: text } });
|
|
68
|
+
switch (message.method) {
|
|
69
|
+
case "initialize":
|
|
70
|
+
return reply({
|
|
71
|
+
protocolVersion: "2025-03-26",
|
|
72
|
+
capabilities: { tools: {} },
|
|
73
|
+
serverInfo: { name, version: "1.0.0" },
|
|
74
|
+
});
|
|
75
|
+
case "ping":
|
|
76
|
+
return reply({});
|
|
77
|
+
case "tools/list":
|
|
78
|
+
return reply({ tools: catalog });
|
|
79
|
+
case "tools/call": {
|
|
80
|
+
const tool = tools[message.params?.name];
|
|
81
|
+
if (!tool) return fail(-32602, "Unknown tool");
|
|
82
|
+
let context;
|
|
83
|
+
try {
|
|
84
|
+
context = readConnectorCallContext(message.params?._meta, { organizationId });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return fail(-32602, error.message);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const text = await tool.run(message.params.arguments ?? {}, context);
|
|
90
|
+
return reply({ content: [{ type: "text", text: String(text) }] });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return reply({
|
|
93
|
+
content: [{ type: "text", text: error instanceof Error ? error.message : "Tool failed" }],
|
|
94
|
+
isError: true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
default:
|
|
99
|
+
return fail(-32601, "Method not found");
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
105
|
+
const { AGENT_SERVICE_URL, ORGANIZATION_ID, CONNECTOR_ID, PORT = "8080" } = process.env;
|
|
106
|
+
if (!AGENT_SERVICE_URL || !ORGANIZATION_ID || !CONNECTOR_ID)
|
|
107
|
+
throw new Error("Set AGENT_SERVICE_URL, ORGANIZATION_ID and CONNECTOR_ID; PORT is optional");
|
|
108
|
+
const handler = createConnectorHandler({
|
|
109
|
+
baseUrl: AGENT_SERVICE_URL,
|
|
110
|
+
organizationId: ORGANIZATION_ID,
|
|
111
|
+
connectorId: CONNECTOR_ID,
|
|
112
|
+
tools: {
|
|
113
|
+
lookup_order: {
|
|
114
|
+
description: "Look up one of the user's orders",
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: { orderId: { type: "string" } },
|
|
118
|
+
required: ["orderId"],
|
|
119
|
+
},
|
|
120
|
+
run: async ({ orderId }, { userId }) =>
|
|
121
|
+
JSON.stringify({ orderId, userId, status: "shipped" }),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
// Serve behind your HTTPS terminator; register its HTTPS URL as endpointUrl.
|
|
126
|
+
createServer(handler).listen(Number(PORT));
|
|
127
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { OrganizationClient } from "@tidblink/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The backend of a smart lamp that users talk to by typing in an app or by
|
|
5
|
+
* speaking to the lamp. Both share one Session, so one conversation history.
|
|
6
|
+
*
|
|
7
|
+
* - The lamp's functions are Client Tools: the model calls them, this backend
|
|
8
|
+
* runs them by forwarding to the lamp over your own device channel (MQTT,
|
|
9
|
+
* WebSocket, ...), and the core never talks to the lamp except for audio.
|
|
10
|
+
* - While the lamp is online the backend keeps its Session connected, so the
|
|
11
|
+
* lamp's tools work for voice too. Typing while the lamp is offline still
|
|
12
|
+
* works; its tools then fail at once and the model says so.
|
|
13
|
+
* - For voice the backend hands the lamp a short-lived grant; the lamp opens
|
|
14
|
+
* the Realtime WebSocket itself and audio never passes through here.
|
|
15
|
+
*
|
|
16
|
+
* `device` is your channel to the lamps:
|
|
17
|
+
* isOnline(deviceId) -> boolean
|
|
18
|
+
* request(deviceId, command, args, { signal }) -> Promise<result>, rejects on failure
|
|
19
|
+
* send(deviceId, topic, payload) -> void (fire-and-forget, e.g. the voice grant)
|
|
20
|
+
* `store` keeps who owns which lamp and where each Session's events were read;
|
|
21
|
+
* the default keeps it in memory.
|
|
22
|
+
*/
|
|
23
|
+
export function createLampBackend({
|
|
24
|
+
baseUrl,
|
|
25
|
+
organizationApiKey,
|
|
26
|
+
templateId,
|
|
27
|
+
device,
|
|
28
|
+
store = memoryStore(),
|
|
29
|
+
onChat = () => {},
|
|
30
|
+
}) {
|
|
31
|
+
const organization = new OrganizationClient({ baseUrl, apiKey: organizationApiKey });
|
|
32
|
+
/** deviceId -> SessionConnection while the lamp is online. */
|
|
33
|
+
const online = new Map();
|
|
34
|
+
|
|
35
|
+
// ── The lamp's functions (edge capabilities) ──────────────────────────────
|
|
36
|
+
const lampTools = (deviceId) => {
|
|
37
|
+
const ask = async (command, args, signal) => {
|
|
38
|
+
// Fail at once instead of letting the model wait for a lamp that is not there.
|
|
39
|
+
if (!device.isOnline(deviceId)) throw new Error("The lamp is offline");
|
|
40
|
+
return device.request(deviceId, command, args, { signal });
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
set_light: {
|
|
44
|
+
description: "Turn the lamp on or off, or set its brightness and colour temperature",
|
|
45
|
+
parameters: {
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
on: { type: "boolean", description: "true turns the lamp on, false off" },
|
|
49
|
+
brightness: { type: "integer", minimum: 1, maximum: 100, description: "Percent" },
|
|
50
|
+
colorTemp: { type: "integer", minimum: 2700, maximum: 6500, description: "Kelvin" },
|
|
51
|
+
},
|
|
52
|
+
additionalProperties: false,
|
|
53
|
+
},
|
|
54
|
+
run: async (args, { signal }) => {
|
|
55
|
+
const state = await ask("set_light", args, signal);
|
|
56
|
+
return state.on
|
|
57
|
+
? `The lamp is on at ${state.brightness}% and ${state.colorTemp} K`
|
|
58
|
+
: "The lamp is off";
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
get_status: {
|
|
62
|
+
description: "Read the lamp's state and the ambient light",
|
|
63
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
64
|
+
run: async (_args, { signal }) => JSON.stringify(await ask("get_status", {}, signal)),
|
|
65
|
+
},
|
|
66
|
+
set_timer: {
|
|
67
|
+
description: "Turn the lamp off after some minutes",
|
|
68
|
+
parameters: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: { minutes: { type: "integer", minimum: 1, maximum: 240 } },
|
|
71
|
+
required: ["minutes"],
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
run: async ({ minutes }, { signal }) => {
|
|
75
|
+
await ask("set_timer", { minutes, action: "off" }, signal);
|
|
76
|
+
return `The lamp turns off in ${minutes} minutes`;
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const sessionOf = async (deviceId) => {
|
|
83
|
+
const binding = await store.binding(deviceId);
|
|
84
|
+
if (!binding) throw new Error(`Lamp ${deviceId} is not bound to a user`);
|
|
85
|
+
return organization.user(binding.userId).session(binding.sessionId);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const connect = async (deviceId) =>
|
|
89
|
+
(await sessionOf(deviceId)).connect({
|
|
90
|
+
tools: lampTools(deviceId),
|
|
91
|
+
cursor: await store.cursor(deviceId),
|
|
92
|
+
onCursor: (cursor) => store.saveCursor(deviceId, cursor),
|
|
93
|
+
// What was said by voice also appears in the app.
|
|
94
|
+
onTranscript: (t) => onChat(deviceId, { role: "user", text: t.text }),
|
|
95
|
+
onResponse: (r) => onChat(deviceId, { role: "assistant", text: r.text }),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
organization,
|
|
100
|
+
|
|
101
|
+
/** The user binds a lamp in the app: one End User per app user, one Session per lamp. */
|
|
102
|
+
async bindDevice(appUserId, deviceId) {
|
|
103
|
+
const user = await organization.createEndUser({ externalId: appUserId }); // safe to repeat
|
|
104
|
+
const session = await organization.user(user.userId).createSession({
|
|
105
|
+
templateId,
|
|
106
|
+
idempotencyKey: `lamp.${deviceId}`, // one Session per lamp, also after a retry
|
|
107
|
+
});
|
|
108
|
+
await store.bind(deviceId, { userId: user.userId, sessionId: session.id });
|
|
109
|
+
return session.id;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/** The lamp came online: keep its Session connected while it stays online. */
|
|
113
|
+
async onDeviceOnline(deviceId) {
|
|
114
|
+
if (online.has(deviceId)) return;
|
|
115
|
+
const conn = await connect(deviceId);
|
|
116
|
+
online.set(deviceId, conn);
|
|
117
|
+
// Another backend process took the lamp over, or the Session was deleted.
|
|
118
|
+
conn.closed.then(() => {
|
|
119
|
+
if (online.get(deviceId) === conn) online.delete(deviceId);
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/** The lamp went offline: release its Session. */
|
|
124
|
+
async onDeviceOffline(deviceId) {
|
|
125
|
+
const conn = online.get(deviceId);
|
|
126
|
+
online.delete(deviceId);
|
|
127
|
+
await conn?.close();
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The user typed in the app. Uses the lamp's connection when it is online,
|
|
132
|
+
* otherwise a temporary one. `onDraft` receives the growing answer.
|
|
133
|
+
*/
|
|
134
|
+
async onAppMessage(deviceId, text, { clientEventId, onDraft } = {}) {
|
|
135
|
+
const existing = online.get(deviceId);
|
|
136
|
+
const conn = existing ?? (await connect(deviceId));
|
|
137
|
+
try {
|
|
138
|
+
const reply = await conn.send(text, {
|
|
139
|
+
...(clientEventId ? { clientEventId } : {}),
|
|
140
|
+
...(onDraft ? { onProgress: onDraft } : {}),
|
|
141
|
+
});
|
|
142
|
+
return reply.text;
|
|
143
|
+
} finally {
|
|
144
|
+
if (!existing) await conn.close();
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/** The user pressed the talk button: give the lamp a short-lived grant to open voice. */
|
|
149
|
+
async onDeviceWantsToTalk(deviceId) {
|
|
150
|
+
const conn = online.get(deviceId);
|
|
151
|
+
if (!conn) throw new Error(`Lamp ${deviceId} is not online`);
|
|
152
|
+
const grant = await conn.realtimeGrant();
|
|
153
|
+
device.send(deviceId, "voice_grant", grant);
|
|
154
|
+
return grant;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/** Release every Session and close the Backend Connections, e.g. on shutdown. */
|
|
158
|
+
async close() {
|
|
159
|
+
online.clear();
|
|
160
|
+
await organization.close();
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** In-memory bindings and cursors; use your database in production. */
|
|
166
|
+
export function memoryStore() {
|
|
167
|
+
const bindings = new Map();
|
|
168
|
+
const cursors = new Map();
|
|
169
|
+
return {
|
|
170
|
+
bind: async (deviceId, binding) => void bindings.set(deviceId, binding),
|
|
171
|
+
binding: async (deviceId) => bindings.get(deviceId),
|
|
172
|
+
cursor: async (deviceId) => cursors.get(deviceId),
|
|
173
|
+
saveCursor: async (deviceId, cursor) => void cursors.set(deviceId, cursor),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { pathToFileURL } from "node:url";
|
|
2
|
-
import {
|
|
2
|
+
import { OrganizationClient } from "@tidblink/server";
|
|
3
3
|
|
|
4
4
|
/** Prepare an existing owned Session for a terminal to connect; this does not open a WebSocket. */
|
|
5
5
|
export async function prepareRealtimeConnection({
|
|
6
6
|
baseUrl,
|
|
7
|
-
|
|
7
|
+
organizationApiKey,
|
|
8
|
+
userId,
|
|
8
9
|
sessionId,
|
|
9
10
|
realtimeModelId,
|
|
10
11
|
audio,
|
|
11
12
|
metadata,
|
|
12
13
|
}) {
|
|
13
|
-
const client = new
|
|
14
|
+
const client = new OrganizationClient({
|
|
15
|
+
baseUrl,
|
|
16
|
+
apiKey: organizationApiKey,
|
|
17
|
+
}).user(userId);
|
|
14
18
|
const catalog = await client.listRealtimeModels();
|
|
15
19
|
const session = client.session(sessionId);
|
|
16
20
|
let selection = await session.getRealtimeModelSelection();
|
|
@@ -35,18 +39,25 @@ if (
|
|
|
35
39
|
) {
|
|
36
40
|
const {
|
|
37
41
|
AGENT_SERVICE_URL,
|
|
38
|
-
|
|
42
|
+
ORGANIZATION_API_KEY,
|
|
43
|
+
END_USER_ID,
|
|
39
44
|
SESSION_ID,
|
|
40
45
|
REALTIME_MODEL_ID,
|
|
41
46
|
} = process.env;
|
|
42
|
-
if (
|
|
47
|
+
if (
|
|
48
|
+
!AGENT_SERVICE_URL ||
|
|
49
|
+
!ORGANIZATION_API_KEY ||
|
|
50
|
+
!END_USER_ID ||
|
|
51
|
+
!SESSION_ID
|
|
52
|
+
) {
|
|
43
53
|
throw new Error(
|
|
44
|
-
"Set AGENT_SERVICE_URL,
|
|
54
|
+
"Set AGENT_SERVICE_URL, ORGANIZATION_API_KEY, END_USER_ID and SESSION_ID; REALTIME_MODEL_ID is optional",
|
|
45
55
|
);
|
|
46
56
|
}
|
|
47
57
|
const result = await prepareRealtimeConnection({
|
|
48
58
|
baseUrl: AGENT_SERVICE_URL,
|
|
49
|
-
|
|
59
|
+
organizationApiKey: ORGANIZATION_API_KEY,
|
|
60
|
+
userId: END_USER_ID,
|
|
50
61
|
sessionId: SESSION_ID,
|
|
51
62
|
...(REALTIME_MODEL_ID ? { realtimeModelId: REALTIME_MODEL_ID } : {}),
|
|
52
63
|
});
|
package/examples/read-usage.mjs
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { pathToFileURL } from "node:url";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
export async function readOwnUsage({ baseUrl, userApiKey, options = {} }) {
|
|
5
|
-
const user = new UserClient({ baseUrl, apiKey: userApiKey });
|
|
6
|
-
return {
|
|
7
|
-
summary: await user.getUsage(options),
|
|
8
|
-
details: await user.getUsageDetails(options),
|
|
9
|
-
};
|
|
10
|
-
}
|
|
2
|
+
import { OrganizationClient } from "@tidblink/server";
|
|
11
3
|
|
|
12
4
|
/** Organization details return the first page; retain nextCursor and the original filters. */
|
|
13
5
|
export async function readUserUsage({
|
|
@@ -38,22 +30,12 @@ if (
|
|
|
38
30
|
tagKey: process.env.TAG_KEY,
|
|
39
31
|
tagValue: process.env.TAG_VALUE,
|
|
40
32
|
};
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
options,
|
|
48
|
-
});
|
|
49
|
-
else if (process.env.USAGE_AUTHORITY === "organization")
|
|
50
|
-
result = await readUserUsage({
|
|
51
|
-
baseUrl,
|
|
52
|
-
organizationApiKey: process.env.ORGANIZATION_API_KEY,
|
|
53
|
-
userId: process.env.END_USER_ID,
|
|
54
|
-
options,
|
|
55
|
-
});
|
|
56
|
-
else throw new TypeError("USAGE_AUTHORITY must be user or organization");
|
|
33
|
+
const result = await readUserUsage({
|
|
34
|
+
baseUrl: process.env.AGENT_SERVICE_URL,
|
|
35
|
+
organizationApiKey: process.env.ORGANIZATION_API_KEY,
|
|
36
|
+
userId: process.env.END_USER_ID,
|
|
37
|
+
options,
|
|
38
|
+
});
|
|
57
39
|
console.log(JSON.stringify(result));
|
|
58
40
|
} catch (error) {
|
|
59
41
|
console.error(error instanceof Error ? error.message : "Usage read failed");
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { pathToFileURL } from "node:url";
|
|
2
|
-
import {
|
|
2
|
+
import { OrganizationClient } from "@tidblink/server";
|
|
3
3
|
|
|
4
4
|
/** Persist sessionId, clientEventId and text before calling; reuse them for recovery. */
|
|
5
5
|
export async function sendMessageWorkflow({
|
|
6
6
|
baseUrl,
|
|
7
|
-
|
|
7
|
+
organizationApiKey,
|
|
8
|
+
userId,
|
|
8
9
|
sessionId,
|
|
9
10
|
clientEventId,
|
|
10
11
|
text,
|
|
11
12
|
timeoutMs,
|
|
12
13
|
signal,
|
|
13
14
|
}) {
|
|
14
|
-
const session = new
|
|
15
|
+
const session = new OrganizationClient({
|
|
16
|
+
baseUrl,
|
|
17
|
+
apiKey: organizationApiKey,
|
|
18
|
+
}).user(userId).session(
|
|
15
19
|
sessionId,
|
|
16
20
|
);
|
|
17
21
|
return session.turn(
|
|
@@ -30,7 +34,8 @@ if (
|
|
|
30
34
|
try {
|
|
31
35
|
const result = await sendMessageWorkflow({
|
|
32
36
|
baseUrl: process.env.AGENT_SERVICE_URL,
|
|
33
|
-
|
|
37
|
+
organizationApiKey: process.env.ORGANIZATION_API_KEY,
|
|
38
|
+
userId: process.env.END_USER_ID,
|
|
34
39
|
sessionId: process.env.SESSION_ID,
|
|
35
40
|
clientEventId: process.env.CLIENT_EVENT_ID,
|
|
36
41
|
text: process.env.MESSAGE_TEXT,
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
-
import {
|
|
4
|
+
import { OrganizationClient } from "@tidblink/server";
|
|
5
5
|
|
|
6
6
|
/** This recording request owns its response; aborting it may interrupt the Turn. */
|
|
7
7
|
export async function submitAudioWorkflow({
|
|
8
8
|
baseUrl,
|
|
9
|
-
|
|
9
|
+
organizationApiKey,
|
|
10
|
+
userId,
|
|
10
11
|
sessionId,
|
|
11
12
|
bytes,
|
|
12
13
|
filename,
|
|
@@ -14,7 +15,10 @@ export async function submitAudioWorkflow({
|
|
|
14
15
|
billingTag,
|
|
15
16
|
signal,
|
|
16
17
|
}) {
|
|
17
|
-
return new
|
|
18
|
+
return new OrganizationClient({
|
|
19
|
+
baseUrl,
|
|
20
|
+
apiKey: organizationApiKey,
|
|
21
|
+
}).user(userId)
|
|
18
22
|
.session(sessionId)
|
|
19
23
|
.audio({
|
|
20
24
|
bytes,
|
|
@@ -34,7 +38,8 @@ if (
|
|
|
34
38
|
throw new Error("Set AUDIO_FILE_PATH to an existing recording");
|
|
35
39
|
const result = await submitAudioWorkflow({
|
|
36
40
|
baseUrl: process.env.AGENT_SERVICE_URL,
|
|
37
|
-
|
|
41
|
+
organizationApiKey: process.env.ORGANIZATION_API_KEY,
|
|
42
|
+
userId: process.env.END_USER_ID,
|
|
38
43
|
sessionId: process.env.SESSION_ID,
|
|
39
44
|
filename: basename(process.env.AUDIO_FILE_PATH),
|
|
40
45
|
bytes: await readFile(process.env.AUDIO_FILE_PATH),
|
package/examples/upload-file.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
-
import {
|
|
4
|
+
import { OrganizationClient } from "@tidblink/server";
|
|
5
5
|
|
|
6
6
|
/** Persist both identities and exact file/message input before calling. */
|
|
7
7
|
export async function uploadFileWorkflow({
|
|
8
8
|
baseUrl,
|
|
9
|
-
|
|
9
|
+
organizationApiKey,
|
|
10
|
+
userId,
|
|
10
11
|
sessionId,
|
|
11
12
|
originalName,
|
|
12
13
|
bytes,
|
|
@@ -15,7 +16,10 @@ export async function uploadFileWorkflow({
|
|
|
15
16
|
text = "Read the attached file.",
|
|
16
17
|
timeoutMs,
|
|
17
18
|
}) {
|
|
18
|
-
const user = new
|
|
19
|
+
const user = new OrganizationClient({
|
|
20
|
+
baseUrl,
|
|
21
|
+
apiKey: organizationApiKey,
|
|
22
|
+
}).user(userId);
|
|
19
23
|
const file = await user.files.upload({ originalName, bytes, idempotencyKey });
|
|
20
24
|
const result = await user
|
|
21
25
|
.session(sessionId)
|
|
@@ -35,7 +39,8 @@ if (
|
|
|
35
39
|
throw new Error("Set FILE_PATH to the file to attach");
|
|
36
40
|
const { result } = await uploadFileWorkflow({
|
|
37
41
|
baseUrl: process.env.AGENT_SERVICE_URL,
|
|
38
|
-
|
|
42
|
+
organizationApiKey: process.env.ORGANIZATION_API_KEY,
|
|
43
|
+
userId: process.env.END_USER_ID,
|
|
39
44
|
sessionId: process.env.SESSION_ID,
|
|
40
45
|
originalName: basename(process.env.FILE_PATH),
|
|
41
46
|
bytes: await readFile(process.env.FILE_PATH),
|