@love-moon/app-sdk 0.3.2
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/CHANGELOG.md +135 -0
- package/README.md +173 -0
- package/dist/index.d.ts +299 -0
- package/dist/index.js +30 -0
- package/dist/react/index.d.ts +364 -0
- package/dist/react/index.js +814 -0
- package/dist/react/styles.css +264 -0
- package/dist/server/index.d.ts +376 -0
- package/dist/server/index.js +1387 -0
- package/examples/01_example/.env.example +17 -0
- package/examples/01_example/README.md +80 -0
- package/examples/01_example/chat-cli.mjs +125 -0
- package/examples/01_example/package-lock.json +52 -0
- package/examples/01_example/package.json +13 -0
- package/examples/02_bff/.env.example +16 -0
- package/examples/02_bff/README.md +63 -0
- package/examples/02_bff/app/api/conductor/[...path]/route.ts +277 -0
- package/examples/02_bff/app/api/conductor/bind/route.ts +45 -0
- package/examples/02_bff/app/layout.tsx +25 -0
- package/examples/02_bff/app/page.tsx +114 -0
- package/examples/02_bff/lib/conductor.ts +60 -0
- package/examples/02_bff/next.config.mjs +9 -0
- package/examples/02_bff/package-lock.json +1001 -0
- package/examples/02_bff/package.json +25 -0
- package/examples/02_bff/tsconfig.json +40 -0
- package/package.json +79 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copy to .env (or export inline) before running.
|
|
2
|
+
|
|
3
|
+
# Conductor backend (no trailing slash). Default is local dev.
|
|
4
|
+
CONDUCTOR_BASE_URL=http://localhost:6152
|
|
5
|
+
|
|
6
|
+
# API token from Conductor → Settings → API Tokens.
|
|
7
|
+
# Keep this secret. The CLI runs entirely server-side, so it stays out of
|
|
8
|
+
# any browser.
|
|
9
|
+
CONDUCTOR_TOKEN=ct_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
10
|
+
|
|
11
|
+
# Which daemon + workspace this CLI should run AI work against.
|
|
12
|
+
# The daemon must already be online and have reported the workspace path.
|
|
13
|
+
CONDUCTOR_DAEMON_HOST=duino-mbp
|
|
14
|
+
CONDUCTOR_WORKSPACE_PATH=/Users/me/work/acme
|
|
15
|
+
|
|
16
|
+
# Display name stamped on the project when it's first created.
|
|
17
|
+
CONDUCTOR_APP_NAME=App SDK CLI Example
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Minimal CLI example
|
|
2
|
+
|
|
3
|
+
The smallest possible app using `@love-moon/app-sdk`. About **35 lines of
|
|
4
|
+
business code** to:
|
|
5
|
+
|
|
6
|
+
1. Connect to a Conductor backend with a user token.
|
|
7
|
+
2. Find-or-create a project bound to a daemon + workspace.
|
|
8
|
+
3. Create an AI task.
|
|
9
|
+
4. Read one prompt from stdin, send it, stream the AI reply to stdout.
|
|
10
|
+
|
|
11
|
+
Pure Node — no React, no BFF, no web server. Use this to learn what the SDK
|
|
12
|
+
does end-to-end before integrating it into your own app.
|
|
13
|
+
|
|
14
|
+
> For a full-stack browser demo (BFF + React widget + SSE bridge), see
|
|
15
|
+
> the sibling [`../02_bff/`](../02_bff/).
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# 1. Build the SDK so the file: dependency resolves.
|
|
21
|
+
cd ../.. # → modules/app-sdk
|
|
22
|
+
npm install
|
|
23
|
+
npm run build
|
|
24
|
+
|
|
25
|
+
# 2. Install the example's local file: link.
|
|
26
|
+
cd examples/01_example
|
|
27
|
+
npm install
|
|
28
|
+
|
|
29
|
+
# 3. Configure.
|
|
30
|
+
cp .env.example .env
|
|
31
|
+
$EDITOR .env
|
|
32
|
+
|
|
33
|
+
# 4. Run. (Either source .env yourself or use a tool like dotenv-cli.)
|
|
34
|
+
export $(cat .env | grep -v '^#' | xargs)
|
|
35
|
+
npm start
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Prerequisites: a running Conductor backend (default `http://localhost:6152`)
|
|
39
|
+
and an online daemon registered for the configured workspace.
|
|
40
|
+
|
|
41
|
+
## Sample session
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
→ connecting to http://localhost:6152
|
|
45
|
+
→ binding project "App SDK CLI Example" on duino-mbp:/Users/me/work/acme
|
|
46
|
+
project p_abc123 (reused)
|
|
47
|
+
You: list the three biggest files in this repo
|
|
48
|
+
→ creating task
|
|
49
|
+
task t_xyz789
|
|
50
|
+
|
|
51
|
+
AI:
|
|
52
|
+
Sure — looking at the workspace…
|
|
53
|
+
1. web/src/app/api/projects/route.ts (998 lines)
|
|
54
|
+
2. web/src/lib/realtime/agent-gateway.ts (...)
|
|
55
|
+
3. ...
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What it deliberately doesn't do
|
|
59
|
+
|
|
60
|
+
- **No multi-turn loop**: sends one prompt and exits. A real CLI would loop
|
|
61
|
+
on stdin with `client.tasks.sendMessage(taskId, content)` for each turn.
|
|
62
|
+
- **No interrupt UI**: nothing reads Ctrl+C to call `tasks.interrupt()`.
|
|
63
|
+
- **No error retry**: on transient `network_error` it just exits with code 1.
|
|
64
|
+
|
|
65
|
+
These are intentionally out of scope — the example is a teaching tool, not
|
|
66
|
+
a finished product. The SDK supports all of them; see `../../README.md`.
|
|
67
|
+
|
|
68
|
+
## Code walkthrough
|
|
69
|
+
|
|
70
|
+
[`chat-cli.mjs`](./chat-cli.mjs) is annotated inline. The four SDK calls
|
|
71
|
+
are:
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
const client = await connect({ baseUrl, bearerToken });
|
|
75
|
+
const project = await client.projects.bind({ name, daemonHost, workspacePath });
|
|
76
|
+
const task = await client.tasks.create({ projectId: project.id, title, initialMessage });
|
|
77
|
+
for await (const delta of client.tasks.streamReply(task.id)) { /* … */ }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Everything else (env parsing, stdin readline, terminal output) is plain Node.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Minimal CLI app: chat with your Conductor daemon from a terminal.
|
|
4
|
+
*
|
|
5
|
+
* What it shows: the four moves any third-party server-side integration
|
|
6
|
+
* needs to perform.
|
|
7
|
+
*
|
|
8
|
+
* 1. connect() — open an AppClient with a Conductor token.
|
|
9
|
+
* 2. client.projects.bind() — idempotent find-or-create of the
|
|
10
|
+
* project (daemon + workspace pair).
|
|
11
|
+
* 3. client.tasks.create() — start a new AI conversation in that
|
|
12
|
+
* project.
|
|
13
|
+
* 4. client.tasks.streamReply() — stream the AI reply token-preview by
|
|
14
|
+
* token-preview to stdout. (Send a
|
|
15
|
+
* message first via tasks.sendMessage,
|
|
16
|
+
* or use `initialMessage` on create.)
|
|
17
|
+
*
|
|
18
|
+
* Total business code: ~35 lines (the rest is config + UX).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createInterface } from 'node:readline/promises';
|
|
22
|
+
import { stdin, stdout, exit, env } from 'node:process';
|
|
23
|
+
import { connect } from '@love-moon/app-sdk/server';
|
|
24
|
+
|
|
25
|
+
function readRequired(key) {
|
|
26
|
+
const v = env[key];
|
|
27
|
+
if (!v) {
|
|
28
|
+
console.error(`Missing env var ${key}. See README for setup.`);
|
|
29
|
+
exit(1);
|
|
30
|
+
}
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const config = {
|
|
35
|
+
baseUrl: env.CONDUCTOR_BASE_URL ?? 'http://localhost:6152',
|
|
36
|
+
token: readRequired('CONDUCTOR_TOKEN'),
|
|
37
|
+
daemonHost: readRequired('CONDUCTOR_DAEMON_HOST'),
|
|
38
|
+
workspacePath: readRequired('CONDUCTOR_WORKSPACE_PATH'),
|
|
39
|
+
appName: env.CONDUCTOR_APP_NAME ?? 'App SDK CLI Example',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
console.log(`→ connecting to ${config.baseUrl}`);
|
|
44
|
+
const client = await connect({
|
|
45
|
+
baseUrl: config.baseUrl,
|
|
46
|
+
bearerToken: config.token,
|
|
47
|
+
onUnauthorized: () => console.error('! token rejected (401)'),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
console.log(`→ binding project "${config.appName}" on ${config.daemonHost}:${config.workspacePath}`);
|
|
51
|
+
const project = await client.projects.bind({
|
|
52
|
+
name: config.appName,
|
|
53
|
+
daemonHost: config.daemonHost,
|
|
54
|
+
workspacePath: config.workspacePath,
|
|
55
|
+
});
|
|
56
|
+
console.log(` project ${project.id} (${project.createdByApp ? 'created' : 'reused'})`);
|
|
57
|
+
|
|
58
|
+
// Prompt the user for the initial message right away, so the task is
|
|
59
|
+
// created with content and the AI starts working immediately.
|
|
60
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
61
|
+
const prompt = await rl.question('You: ');
|
|
62
|
+
rl.close();
|
|
63
|
+
if (!prompt.trim()) {
|
|
64
|
+
console.log('(empty input — nothing to do)');
|
|
65
|
+
await client.close();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log(`→ creating task`);
|
|
70
|
+
const task = await client.tasks.create({
|
|
71
|
+
projectId: project.id,
|
|
72
|
+
title: prompt.slice(0, 60),
|
|
73
|
+
initialMessage: prompt,
|
|
74
|
+
});
|
|
75
|
+
console.log(` task ${task.id}`);
|
|
76
|
+
|
|
77
|
+
// Stream the AI reply. streamReply yields cumulative text previews; we
|
|
78
|
+
// print only the new tail each time so the terminal shows a smooth flow.
|
|
79
|
+
console.log('\nAI:');
|
|
80
|
+
let printedSoFar = '';
|
|
81
|
+
for await (const delta of client.tasks.streamReply(task.id)) {
|
|
82
|
+
if (delta.type === 'text') {
|
|
83
|
+
// The first delta is the full preview-so-far; later deltas are
|
|
84
|
+
// already diffed by the SDK. Most of the time the cumulative preview
|
|
85
|
+
// grows monotonically, but the backend may emit a reset (e.g. a tool
|
|
86
|
+
// call replaces the text). When that happens we print the whole new
|
|
87
|
+
// text rather than a corrupt slice.
|
|
88
|
+
stdout.write(delta.text);
|
|
89
|
+
if (delta.text && delta.text.startsWith(printedSoFar)) {
|
|
90
|
+
printedSoFar = delta.text;
|
|
91
|
+
} else {
|
|
92
|
+
// Reset path: the SDK already detected a non-continuation and is
|
|
93
|
+
// handing us the FULL new preview as `delta.text`, not an
|
|
94
|
+
// incremental tail. Tracking `printedSoFar + delta.text` here would
|
|
95
|
+
// double-count and corrupt the prefix used by the 'done' fill-in
|
|
96
|
+
// below.
|
|
97
|
+
printedSoFar = delta.text;
|
|
98
|
+
}
|
|
99
|
+
} else if (delta.type === 'done') {
|
|
100
|
+
// streamReply guarantees a single 'done' carrying the persisted message.
|
|
101
|
+
// If the rolling previews missed any trailing characters, fill them in
|
|
102
|
+
// (only when the persisted content is a continuation of what we printed).
|
|
103
|
+
const full = delta.message.content;
|
|
104
|
+
if (full.startsWith(printedSoFar) && printedSoFar.length < full.length) {
|
|
105
|
+
stdout.write(full.slice(printedSoFar.length));
|
|
106
|
+
}
|
|
107
|
+
stdout.write('\n');
|
|
108
|
+
break;
|
|
109
|
+
} else if (delta.type === 'error') {
|
|
110
|
+
stdout.write(`\n! AI failed: ${delta.error.message}\n`);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
// 'status' deltas are ignored in this minimal example.
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await client.close();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main().catch((err) => {
|
|
120
|
+
// ConductorAppError has a stable `code` field for branching; for a CLI we
|
|
121
|
+
// just print message + code.
|
|
122
|
+
const code = err?.code ? ` [${err.code}]` : '';
|
|
123
|
+
console.error(`\n! ${err.message ?? err}${code}`);
|
|
124
|
+
exit(1);
|
|
125
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "conductor-app-sdk-cli-example",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"": {
|
|
8
|
+
"name": "conductor-app-sdk-cli-example",
|
|
9
|
+
"version": "0.0.0",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@love-moon/app-sdk": "file:../.."
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"../..": {
|
|
15
|
+
"name": "@love-moon/app-sdk",
|
|
16
|
+
"version": "0.1.0",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"ws": "^8.18.0",
|
|
19
|
+
"zod": "^3.24.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^22.10.2",
|
|
23
|
+
"@types/react": "^19.0.0",
|
|
24
|
+
"@types/react-dom": "^19.0.0",
|
|
25
|
+
"@types/ws": "^8.5.12",
|
|
26
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
27
|
+
"jsdom": "^25.0.1",
|
|
28
|
+
"react": "^19.0.0",
|
|
29
|
+
"react-dom": "^19.0.0",
|
|
30
|
+
"tsup": "^8.3.5",
|
|
31
|
+
"typescript": "^5.6.3",
|
|
32
|
+
"vitest": "^2.1.4"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": ">=18.0.0",
|
|
36
|
+
"react-dom": ">=18.0.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"react": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"react-dom": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"node_modules/@love-moon/app-sdk": {
|
|
48
|
+
"resolved": "../..",
|
|
49
|
+
"link": true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "conductor-app-sdk-cli-example",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Minimal CLI app that talks to a user's Conductor daemon via @love-moon/app-sdk.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node chat-cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@love-moon/app-sdk": "file:../.."
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Copy to .env.local and fill in.
|
|
2
|
+
|
|
3
|
+
# Conductor backend base URL (no trailing slash).
|
|
4
|
+
CONDUCTOR_BASE_URL=http://localhost:6152
|
|
5
|
+
|
|
6
|
+
# API token issued from Conductor Settings → API Tokens.
|
|
7
|
+
# IMPORTANT: keep this server-side only. Never embed in client code.
|
|
8
|
+
CONDUCTOR_TOKEN=ct_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
9
|
+
|
|
10
|
+
# The daemon + workspace this app should run AI tasks against.
|
|
11
|
+
# Must already be online + registered with Conductor.
|
|
12
|
+
CONDUCTOR_DAEMON_HOST=duino-mbp
|
|
13
|
+
CONDUCTOR_WORKSPACE_PATH=/Users/me/work/acme
|
|
14
|
+
|
|
15
|
+
# The display name shown to the user inside Conductor for this app.
|
|
16
|
+
CONDUCTOR_APP_NAME=App SDK Demo
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Conductor App SDK — end-to-end demo
|
|
2
|
+
|
|
3
|
+
A tiny Next.js app that uses `@love-moon/app-sdk` to embed a chat with a
|
|
4
|
+
user's Conductor AI tool. **Total business code: ~120 lines.**
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────────┐
|
|
8
|
+
│ browser: │ │ /api/conductor │ │ Conductor backend │
|
|
9
|
+
│ <ChatView /> │ ──── │ Next.js BFF │ ──── │ + /ws/app + daemon │
|
|
10
|
+
│ + REST adapter │ SSE │ app-sdk/server │ │ │
|
|
11
|
+
└─────────────────┘ └─────────────────┘ └──────────────────────┘
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Run it
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# 1. Build the SDK (the example uses file: link to ../..).
|
|
18
|
+
cd ../.. && npm run build && cd -
|
|
19
|
+
|
|
20
|
+
# 2. Install + start.
|
|
21
|
+
npm install
|
|
22
|
+
cp .env.example .env.local
|
|
23
|
+
$EDITOR .env.local # fill in CONDUCTOR_TOKEN + DAEMON_HOST + WORKSPACE_PATH
|
|
24
|
+
npm run dev # → http://localhost:3001
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
You need a running Conductor backend (typically `http://localhost:6152` via
|
|
28
|
+
`cd web && pnpm dev`) and an online daemon (typically `make debug-cli`).
|
|
29
|
+
|
|
30
|
+
## What it shows
|
|
31
|
+
|
|
32
|
+
| File | Concern |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `lib/conductor.ts` | Server-side singleton `AppClient`; `projects.bind()` to find-or-create the demo project (idempotent). |
|
|
35
|
+
| `app/api/conductor/bind/route.ts` | Page bootstrap: bind project + create a fresh task; return ids. |
|
|
36
|
+
| `app/api/conductor/[...path]/route.ts` | Catch-all BFF that forwards 4 routes to Conductor:<br>• `GET /tasks/:id/messages` → `tasks.history()`<br>• `POST /tasks/:id/messages` → `tasks.sendMessage()`<br>• `POST /tasks/:id/interrupt` → `tasks.interrupt()`<br>• `GET /tasks/:id/events` → SSE bridge over `tasks.subscribe()` |
|
|
37
|
+
| `app/page.tsx` | React page: bootstrap, mount `<ChatView />`. |
|
|
38
|
+
|
|
39
|
+
The SSE bridge (~30 lines in the catch-all) is the most interesting piece —
|
|
40
|
+
that's how the SDK's server-side AsyncIterable becomes a client-side
|
|
41
|
+
EventSource without needing a custom Next.js server.
|
|
42
|
+
|
|
43
|
+
## What it deliberately skips
|
|
44
|
+
|
|
45
|
+
- **End-user authentication**: the BFF trusts the local browser session. A
|
|
46
|
+
real app would gate every route behind its own auth (cookie / JWT).
|
|
47
|
+
- **Persistence**: every page load creates a new task. A real app would
|
|
48
|
+
persist `(userId, taskId)` somewhere and resume.
|
|
49
|
+
- **CSRF protection**: standard Next.js patterns apply, not shown here.
|
|
50
|
+
- **Rate limiting**: trivial to add at the BFF (per user / per IP).
|
|
51
|
+
|
|
52
|
+
These are intentionally out of scope — they're host application concerns,
|
|
53
|
+
not SDK concerns.
|
|
54
|
+
|
|
55
|
+
## Common errors
|
|
56
|
+
|
|
57
|
+
| Symptom | Cause |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `Missing env var CONDUCTOR_TOKEN` on bind | `.env.local` not configured. |
|
|
60
|
+
| Bind returns `daemon_offline` | Daemon at `CONDUCTOR_DAEMON_HOST` isn't running. Start the daemon (`make debug-cli` for dev). |
|
|
61
|
+
| Bind returns `binding_validation_failed` | Daemon doesn't recognize the `CONDUCTOR_WORKSPACE_PATH`. Use a path the daemon has already reported. |
|
|
62
|
+
| SSE silently disconnects after ~30s | Reverse proxy buffering. The response sets `X-Accel-Buffering: no`; check your proxy config. |
|
|
63
|
+
| `401 unauthorized` | Token revoked or invalid. Mint a new one in Conductor Settings → API Tokens. |
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BFF pass-through for the widget's default REST adapter.
|
|
3
|
+
*
|
|
4
|
+
* Routes (relative to this catch-all):
|
|
5
|
+
*
|
|
6
|
+
* GET /tasks/:taskId/messages?pagination=1&limit&before_id
|
|
7
|
+
* → forwarded to client.tasks.history()
|
|
8
|
+
* POST /tasks/:taskId/messages
|
|
9
|
+
* → forwarded to client.tasks.sendMessage()
|
|
10
|
+
* POST /tasks/:taskId/interrupt
|
|
11
|
+
* → forwarded to client.tasks.interrupt()
|
|
12
|
+
* GET /tasks/:taskId/events
|
|
13
|
+
* → SSE stream from client.tasks.subscribe()
|
|
14
|
+
*
|
|
15
|
+
* In a real BFF you'd:
|
|
16
|
+
* - Authenticate the requesting *user* (cookie / JWT) before each call.
|
|
17
|
+
* - Look up which Conductor task they're allowed to drive.
|
|
18
|
+
* - Probably rate-limit per user.
|
|
19
|
+
* - Possibly translate / strip metadata before forwarding to Conductor.
|
|
20
|
+
*
|
|
21
|
+
* The demo skips all of that — it trusts the local browser session.
|
|
22
|
+
*/
|
|
23
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
24
|
+
import { getClient } from '@/lib/conductor';
|
|
25
|
+
import { isConductorAppError, ConductorAppError } from '@love-moon/app-sdk';
|
|
26
|
+
|
|
27
|
+
export const runtime = 'nodejs';
|
|
28
|
+
// SSE streams are long-lived; tell Next not to time them out.
|
|
29
|
+
export const dynamic = 'force-dynamic';
|
|
30
|
+
|
|
31
|
+
interface RouteContext {
|
|
32
|
+
params: Promise<{ path: string[] }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function GET(req: NextRequest, ctx: RouteContext) {
|
|
36
|
+
const segments = (await ctx.params).path ?? [];
|
|
37
|
+
// Expect /tasks/:taskId/<messages|events>
|
|
38
|
+
if (segments[0] !== 'tasks' || !segments[1]) return notFound();
|
|
39
|
+
const taskId = decodeURIComponent(segments[1]);
|
|
40
|
+
const op = segments[2];
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const client = await getClient();
|
|
44
|
+
if (op === 'messages') {
|
|
45
|
+
const url = new URL(req.url);
|
|
46
|
+
const beforeId = url.searchParams.get('before_id') ?? undefined;
|
|
47
|
+
const limitParam = url.searchParams.get('limit');
|
|
48
|
+
const limit = limitParam ? Number.parseInt(limitParam, 10) : undefined;
|
|
49
|
+
const page = await client.tasks.history(taskId, { beforeId, limit });
|
|
50
|
+
// Translate to the wire shape the widget's REST adapter expects.
|
|
51
|
+
return NextResponse.json({
|
|
52
|
+
messages: page.messages,
|
|
53
|
+
pagination: {
|
|
54
|
+
has_more_before: page.hasMoreBefore,
|
|
55
|
+
oldest_message_id: page.oldestMessageId,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (op === 'events') {
|
|
61
|
+
return startEventStream(req, taskId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return notFound();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return errorResponse(err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function POST(req: NextRequest, ctx: RouteContext) {
|
|
71
|
+
const segments = (await ctx.params).path ?? [];
|
|
72
|
+
if (segments[0] !== 'tasks' || !segments[1]) return notFound();
|
|
73
|
+
const taskId = decodeURIComponent(segments[1]);
|
|
74
|
+
const op = segments[2];
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const client = await getClient();
|
|
78
|
+
const body = await req.json().catch(() => ({}));
|
|
79
|
+
|
|
80
|
+
if (op === 'messages') {
|
|
81
|
+
const content = String(body?.content ?? '');
|
|
82
|
+
if (!content) {
|
|
83
|
+
return NextResponse.json({ error: 'content required' }, { status: 400 });
|
|
84
|
+
}
|
|
85
|
+
// Threat model: the BFF is a trust boundary between the browser and
|
|
86
|
+
// Conductor. The browser cannot be trusted to:
|
|
87
|
+
// - Author messages as `system` / `assistant` (would let an attacker
|
|
88
|
+
// forge AI replies in the chat log).
|
|
89
|
+
// - Stamp `audit.actor='app'` (would let an attacker disguise a
|
|
90
|
+
// browser-originated message as a server-side app message and
|
|
91
|
+
// defeat `streamReply`'s SDK-echo filter).
|
|
92
|
+
// We therefore hard-code role='user' and strip metadata.audit before
|
|
93
|
+
// forwarding. The SDK then stamps its own audit fields server-side.
|
|
94
|
+
const incomingMetadata =
|
|
95
|
+
body?.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata)
|
|
96
|
+
? { ...(body.metadata as Record<string, unknown>) }
|
|
97
|
+
: undefined;
|
|
98
|
+
if (incomingMetadata) delete incomingMetadata.audit;
|
|
99
|
+
const msg = await client.tasks.sendMessage(taskId, {
|
|
100
|
+
content,
|
|
101
|
+
clientRequestId: typeof body?.clientRequestId === 'string' ? body.clientRequestId : undefined,
|
|
102
|
+
role: 'user',
|
|
103
|
+
...(incomingMetadata ? { metadata: incomingMetadata } : {}),
|
|
104
|
+
});
|
|
105
|
+
return NextResponse.json(msg);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (op === 'interrupt') {
|
|
109
|
+
const targetReplyTo = String(body?.target_reply_to ?? body?.targetReplyTo ?? '');
|
|
110
|
+
if (!targetReplyTo) {
|
|
111
|
+
return NextResponse.json({ error: 'target_reply_to required' }, { status: 400 });
|
|
112
|
+
}
|
|
113
|
+
await client.tasks.interrupt(taskId, { targetReplyTo });
|
|
114
|
+
return NextResponse.json({ ok: true });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return notFound();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return errorResponse(err);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Bridge the SDK's `subscribe(taskId)` AsyncIterable to a Server-Sent Events
|
|
125
|
+
* response. The widget connects via `new EventSource(...)` and renders each
|
|
126
|
+
* `data: <JSON>` line as a ChatEvent.
|
|
127
|
+
*
|
|
128
|
+
* Lifecycle:
|
|
129
|
+
* - Client navigates away → req.signal aborts → we break the loop, the
|
|
130
|
+
* AsyncIterator's return() runs, and the underlying WS subscription is
|
|
131
|
+
* released.
|
|
132
|
+
*/
|
|
133
|
+
async function startEventStream(req: NextRequest, taskId: string): Promise<Response> {
|
|
134
|
+
const client = await getClient();
|
|
135
|
+
const encoder = new TextEncoder();
|
|
136
|
+
const abortController = new AbortController();
|
|
137
|
+
// Name the listener so we can remove it in cleanup. Otherwise long-lived
|
|
138
|
+
// edge runtimes / proxies that reuse the same request signal would leak
|
|
139
|
+
// listeners across SSE streams.
|
|
140
|
+
const onRequestAbort = (): void => abortController.abort();
|
|
141
|
+
let removeReqAbortListener: (() => void) | null = null;
|
|
142
|
+
if (req.signal.aborted) {
|
|
143
|
+
abortController.abort();
|
|
144
|
+
} else {
|
|
145
|
+
req.signal.addEventListener('abort', onRequestAbort, { once: true });
|
|
146
|
+
removeReqAbortListener = () => {
|
|
147
|
+
req.signal.removeEventListener('abort', onRequestAbort);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Keep-alive timer: emit an SSE comment every 15s to keep idle
|
|
152
|
+
// connections alive past proxy timeouts (nginx default 60s,
|
|
153
|
+
// some CDNs lower).
|
|
154
|
+
let keepAliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
155
|
+
let closed = false;
|
|
156
|
+
|
|
157
|
+
const stream = new ReadableStream({
|
|
158
|
+
async start(controller) {
|
|
159
|
+
// Safe wrapper: enqueue can throw `TypeError` once the controller
|
|
160
|
+
// is closed (e.g. after `cancel()` fires). Returning false signals
|
|
161
|
+
// the producer loop to bail out.
|
|
162
|
+
const safeEnqueue = (chunk: Uint8Array): boolean => {
|
|
163
|
+
if (closed) return false;
|
|
164
|
+
try {
|
|
165
|
+
controller.enqueue(chunk);
|
|
166
|
+
return true;
|
|
167
|
+
} catch {
|
|
168
|
+
closed = true;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// SSE preamble: a comment line tells some proxies "keep this alive".
|
|
174
|
+
if (!safeEnqueue(encoder.encode(':ok\n\n'))) return;
|
|
175
|
+
|
|
176
|
+
keepAliveTimer = setInterval(() => {
|
|
177
|
+
// Backpressure check: if the client isn't draining the stream fast
|
|
178
|
+
// enough (negative desiredSize means the internal queue is over the
|
|
179
|
+
// high-water mark), skip this keep-alive tick rather than piling
|
|
180
|
+
// more chunks into the buffer. The real events that follow are
|
|
181
|
+
// small; missing a keep-alive comment is harmless.
|
|
182
|
+
if (typeof controller.desiredSize === 'number' && controller.desiredSize < 0) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!safeEnqueue(encoder.encode(': keepalive\n\n'))) {
|
|
186
|
+
if (keepAliveTimer) {
|
|
187
|
+
clearInterval(keepAliveTimer);
|
|
188
|
+
keepAliveTimer = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}, 15_000);
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
for await (const event of client.tasks.subscribe(taskId, {
|
|
195
|
+
signal: abortController.signal,
|
|
196
|
+
})) {
|
|
197
|
+
if (closed || req.signal.aborted) break;
|
|
198
|
+
const ok = safeEnqueue(
|
|
199
|
+
encoder.encode(`data: ${JSON.stringify(event)}\n\n`),
|
|
200
|
+
);
|
|
201
|
+
if (!ok) break;
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
// Surface terminal errors as a synthetic event then close. Preserve
|
|
205
|
+
// the original ConductorAppError `code` (e.g. `task_not_running`,
|
|
206
|
+
// `daemon_offline`) rather than hard-coding `subscribe_failed` — the
|
|
207
|
+
// browser-side widget switches its UI hint based on the code, so
|
|
208
|
+
// collapsing every terminal error to a single bucket would lose
|
|
209
|
+
// useful disambiguation. Fall back to `subscribe_failed` only when
|
|
210
|
+
// the thrown value isn't an SDK error.
|
|
211
|
+
const code =
|
|
212
|
+
err instanceof ConductorAppError ? err.code : 'subscribe_failed';
|
|
213
|
+
const payload = {
|
|
214
|
+
type: 'task_failed',
|
|
215
|
+
taskId,
|
|
216
|
+
error: {
|
|
217
|
+
code,
|
|
218
|
+
message: (err as Error)?.message ?? 'subscribe stream ended',
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
safeEnqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
|
222
|
+
} finally {
|
|
223
|
+
cleanup();
|
|
224
|
+
if (!closed) {
|
|
225
|
+
try {
|
|
226
|
+
controller.close();
|
|
227
|
+
} catch {
|
|
228
|
+
/* already closed */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
cancel() {
|
|
234
|
+
abortController.abort();
|
|
235
|
+
cleanup();
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
function cleanup() {
|
|
240
|
+
closed = true;
|
|
241
|
+
if (keepAliveTimer) {
|
|
242
|
+
clearInterval(keepAliveTimer);
|
|
243
|
+
keepAliveTimer = null;
|
|
244
|
+
}
|
|
245
|
+
if (removeReqAbortListener) {
|
|
246
|
+
removeReqAbortListener();
|
|
247
|
+
removeReqAbortListener = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return new Response(stream, {
|
|
252
|
+
headers: {
|
|
253
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
254
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
255
|
+
Connection: 'keep-alive',
|
|
256
|
+
// Prevent buffering proxies (nginx) from holding events back.
|
|
257
|
+
'X-Accel-Buffering': 'no',
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function notFound() {
|
|
263
|
+
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function errorResponse(err: unknown) {
|
|
267
|
+
if (isConductorAppError(err)) {
|
|
268
|
+
return NextResponse.json(
|
|
269
|
+
{ error: err.message, code: err.code },
|
|
270
|
+
{ status: err.status ?? 500 },
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return NextResponse.json(
|
|
274
|
+
{ error: (err as Error)?.message ?? 'Internal error' },
|
|
275
|
+
{ status: 500 },
|
|
276
|
+
);
|
|
277
|
+
}
|