dopus 0.2.5 → 0.2.6
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 +124 -5
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +45 -4
- package/dist/{version-DmrGrSmM.js → version-Dm8IBEUF.js} +1 -1
- package/package.json +6 -3
- package/templates/ui/agent-chat.css +6 -0
- package/templates/ui/agent-chat.tsx +115 -2
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# dopus
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A lightweight agent for Next.js. Hand it the server functions your app already has, and it does the user's tasks with
|
|
4
|
+
them, with the user's own permissions.
|
|
4
5
|
|
|
5
6
|
```bash
|
|
6
7
|
pnpm add dopus
|
|
@@ -14,6 +15,21 @@ pnpm dopus init
|
|
|
14
15
|
|
|
15
16
|
Built on the [Vercel AI SDK](https://ai-sdk.dev) 7. Supports OpenAI, Anthropic and Google models.
|
|
16
17
|
|
|
18
|
+
**What it is for.** An assistant inside your app that answers questions about the user's data and makes small changes
|
|
19
|
+
on request: "show my open orders", "move this task to done", "assign the deploy checklist to Carol". Each turn is a few
|
|
20
|
+
quick server-function calls inside one request, usually a few seconds.
|
|
21
|
+
|
|
22
|
+
**What it is not.** dopus stays small on purpose. These are out of scope:
|
|
23
|
+
|
|
24
|
+
- Running long work itself. A tool should finish in seconds. Longer jobs run in your own queue, Lambda or worker; a
|
|
25
|
+
tool starts them, and the agent picks up again when your app announces they finished (see
|
|
26
|
+
[Background jobs](#background-jobs)).
|
|
27
|
+
- Autonomous, scheduled or multi-agent workflows.
|
|
28
|
+
- Built-in memory, RAG or a vector store. If you need search over documents, expose it as a tool.
|
|
29
|
+
- Frameworks other than Next.js, and the Pages Router.
|
|
30
|
+
- Running the agent somewhere other than your Next.js app. `dopus infra` only writes the deploy config that keeps the
|
|
31
|
+
agent route streaming on AWS, Cloudflare or Cloud Run.
|
|
32
|
+
|
|
17
33
|
---
|
|
18
34
|
|
|
19
35
|
## Quick start
|
|
@@ -108,6 +124,20 @@ export default defineAgent({
|
|
|
108
124
|
|
|
109
125
|
`ctx` is `{ user, toolCallId, abortSignal }`. `user` is only set when you configure `identify` (see below).
|
|
110
126
|
|
|
127
|
+
### Designing tools
|
|
128
|
+
|
|
129
|
+
A lightweight agent works best with small, fast tools:
|
|
130
|
+
|
|
131
|
+
- **Few.** Register the tools a user needs here, not every function you have. Past roughly 20 to 30 tools, models pick
|
|
132
|
+
the wrong one more often, and every request carries every description.
|
|
133
|
+
- **Fast.** A tool should return in a second or two. Longer work belongs in a job (see
|
|
134
|
+
[Timeouts and long-running tools](#timeouts-and-long-running-tools)).
|
|
135
|
+
- **Small results.** Take filters and a limit, and return only the fields the agent needs (`output` helps). A thousand
|
|
136
|
+
rows cost tokens on every later step, and the whole history counts against `limits.maxBodyBytes`.
|
|
137
|
+
- **Batch, don't loop.** "Close all 200 stale tickets" should be one server function that takes a filter, not 200 calls.
|
|
138
|
+
A turn allows at most `maxSteps` (10) model ↔ tool round trips.
|
|
139
|
+
- **Clear names and descriptions.** The model chooses by them. Say when _not_ to use a tool in `usage`.
|
|
140
|
+
|
|
111
141
|
### Errors
|
|
112
142
|
|
|
113
143
|
Only two kinds of error message reach the model:
|
|
@@ -151,7 +181,7 @@ Your part, so an injected instruction cannot cause damage:
|
|
|
151
181
|
|
|
152
182
|
We tested this with a real model in a small task-board app. A member wrote a task whose title ordered the assistant to delete other tasks, then an admin's assistant listed the tasks. Claude (claude-sonnet-5-5) did not even request the deletion and pointed out the planted text. If a model did request it, the approval card would still stop it. Other models were not tested this way.
|
|
153
183
|
|
|
154
|
-
**Running tools outside the request.** Background jobs and queues lose the request's cookies. For that case, define `identify(request)` to resolve a serialisable user. Tools then receive it as `ctx.user` and can pass it on explicitly.
|
|
184
|
+
**Running tools outside the request.** Background jobs and queues lose the request's cookies. For that case, define `identify(request)` to resolve a serialisable user. Tools then receive it as `ctx.user` and can pass it on explicitly. dopus itself always runs tools inside the request; this is for jobs your tools hand off.
|
|
155
185
|
|
|
156
186
|
## Prompts
|
|
157
187
|
|
|
@@ -173,7 +203,8 @@ Anthropic gets an explicit cache breakpoint after layer 3. OpenAI and Gemini cac
|
|
|
173
203
|
- `<AgentChat />`: the panel;
|
|
174
204
|
- `<AgentWidget />`: a floating launcher that opens the panel.
|
|
175
205
|
|
|
176
|
-
Both accept `api`, `suggestions`, `labels` (every UI string, for translation) and `
|
|
206
|
+
Both accept `api`, `suggestions`, `labels` (every UI string, for translation), `className`, and `channel` /
|
|
207
|
+
`maxJobFollowUps` for [background jobs](#background-jobs).
|
|
177
208
|
|
|
178
209
|
The UI shows each tool call as it runs. It renders an approve/decline card for confirmed tools and continues automatically once the user answers. When a tool has run, it calls `router.refresh()` so server components show the changed data. Closing the widget keeps the conversation, and a "New chat" button clears it. Assistant replies render as Markdown (tables, lists, code) through `react-markdown` with `remark-gfm`, which `init` installs. Raw HTML is never rendered, images show only their alt text, and links keep only http(s), mailto and relative URLs. If a request is over the size limit (413), the UI says so and offers a new chat instead of a useless retry. It also has stop and retry buttons, and it does not send on Enter while an IME (Korean, Japanese, Chinese) is still composing.
|
|
179
210
|
|
|
@@ -288,11 +319,98 @@ The job runs in your queue or background worker, outside the request, so it does
|
|
|
288
319
|
permissions in `startExportJob` and pass the user to the job explicitly (see `identify` and `ctx.user`). Have `getExportJob`
|
|
289
320
|
check that the job belongs to the caller.
|
|
290
321
|
|
|
322
|
+
With a status tool alone, the user has to ask "is it done?". To have the agent continue by itself when the job finishes,
|
|
323
|
+
mark the start tool with `job` and give the chat UI a channel: see [Background jobs](#background-jobs).
|
|
324
|
+
|
|
291
325
|
**If a turn is cut anyway**, tools that already ran stay done. "Retry" asks the model again and may call a tool again.
|
|
292
326
|
Tools behind `confirm: true` need a new approval; make other writes idempotent.
|
|
293
327
|
|
|
328
|
+
## Background jobs
|
|
329
|
+
|
|
330
|
+
For work that takes longer than a request: a Lambda that calls an LLM for a minute, a report, a bulk import. The tool
|
|
331
|
+
starts the job and returns at once; your worker does the work; your app announces "job X finished" on a channel it
|
|
332
|
+
already has (Supabase Realtime, a WebSocket, SSE, polling). The chat UI hears it and the agent continues: it reads the
|
|
333
|
+
result with another of your tools and does what the user asked next.
|
|
334
|
+
|
|
335
|
+
```
|
|
336
|
+
user: "build the workload report, then assign unassigned tasks to whoever is least busy"
|
|
337
|
+
→ startWorkloadReport returns { jobId } at once; the agent says it started, the turn ends
|
|
338
|
+
… your worker runs for a minute, saves the result, publishes "job-7 finished" on your channel …
|
|
339
|
+
→ the chat UI hears job-7 sends a notice carrying only the ID; a new turn starts
|
|
340
|
+
→ getReportResult({ jobId }) your tool reads the result and checks it belongs to the user
|
|
341
|
+
→ assignTask(...) confirmed tools still stop at the approval card
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
**Server**: mark the tool that starts the job, and add a tool that reads the result.
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
startReport: defineTool(() => startReportJob(), { // invoke the Lambda / enqueue, return { jobId } at once
|
|
348
|
+
description: 'Starts the workload report. Takes about a minute.',
|
|
349
|
+
input: z.object({}),
|
|
350
|
+
job: (result) => result.jobId, // this tool starts a background job
|
|
351
|
+
}),
|
|
352
|
+
getReportResult: defineTool((input) => readReport(input.jobId), { // check the job belongs to the caller
|
|
353
|
+
description: 'Reads a report job by its id: status and, when done, the report.',
|
|
354
|
+
usage: 'After the app says a report job has finished.',
|
|
355
|
+
input: z.object({ jobId: z.string() }),
|
|
356
|
+
}),
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`job` returns the job's ID (letters, digits, `_ . : -`, up to 128 characters). dopus tells the model the job runs in
|
|
360
|
+
the background and that it will be told when it finishes, so it does not poll.
|
|
361
|
+
|
|
362
|
+
**Client**: give the chat a `channel`. It holds functions, so create it in a client component, once:
|
|
363
|
+
|
|
364
|
+
```tsx
|
|
365
|
+
// components/agent/agent.tsx
|
|
366
|
+
'use client'
|
|
367
|
+
import { type AgentChannel, type AgentChatProps, AgentWidget } from './agent-chat'
|
|
368
|
+
import { supabase } from '@/lib/supabase/client'
|
|
369
|
+
|
|
370
|
+
const channel: AgentChannel = {
|
|
371
|
+
subscribe(finished) {
|
|
372
|
+
const ch = supabase
|
|
373
|
+
.channel('jobs', { config: { private: true } })
|
|
374
|
+
.on('broadcast', { event: 'job-done' }, ({ payload }) => finished(payload.jobId))
|
|
375
|
+
.subscribe()
|
|
376
|
+
return () => {
|
|
377
|
+
supabase.removeChannel(ch)
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function Agent(props: AgentChatProps) {
|
|
383
|
+
return <AgentWidget {...props} channel={channel} />
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Any source works: `new EventSource(url)`, a WebSocket, or a `setInterval` that asks your API which jobs finished. Call
|
|
388
|
+
`finished(jobId)` for each one; return a function that stops listening. Then use `<Agent />` in your layout instead of
|
|
389
|
+
`<AgentWidget />`.
|
|
390
|
+
|
|
391
|
+
**How it is kept safe:**
|
|
392
|
+
|
|
393
|
+
- **The channel is a doorbell, not a mailbox.** Only the job ID goes from the channel to the server. The text the model
|
|
394
|
+
sees ("Background job X has finished…") is written by the server, and the result comes from your tool, which checks
|
|
395
|
+
permissions. A forged or replayed event can at most make the agent look up a job the user can already read.
|
|
396
|
+
- **Only this chat's jobs.** The UI reacts only to IDs that a tool in this conversation returned and that it has not
|
|
397
|
+
reported yet. Another tab, or a new chat, ignores them.
|
|
398
|
+
- **Follow-ups are capped.** After `maxJobFollowUps` (default 3) automatic turns in a row, a finished job is shown in
|
|
399
|
+
the chat but the agent waits for the user's next message, so a job that starts another job cannot loop.
|
|
400
|
+
- **Approvals still apply.** An automatic turn stops at the approval card like any other.
|
|
401
|
+
|
|
402
|
+
**Limits:**
|
|
403
|
+
|
|
404
|
+
- The follow-up runs only while the chat is open in a browser. If the user closes the tab, the result is still in your
|
|
405
|
+
app (show it there); the agent just does not act on it. Work that must continue without the user belongs in your
|
|
406
|
+
worker, not in the agent.
|
|
407
|
+
- Each finished job costs one more model call.
|
|
408
|
+
|
|
294
409
|
## Deploying
|
|
295
410
|
|
|
411
|
+
The `dopus infra` commands write the deploy configuration that keeps the agent route streaming within each platform's
|
|
412
|
+
limits. The agent still runs inside your Next.js app; nothing is moved elsewhere.
|
|
413
|
+
|
|
296
414
|
**Vercel / any Node server.** Nothing extra. The route streams. Raise `maxDuration` in `app/api/agent/route.ts` if your plan allows it; the time budget follows it.
|
|
297
415
|
|
|
298
416
|
**AWS (OpenNext / SST).**
|
|
@@ -370,9 +488,10 @@ Swap in a scripted `MockLanguageModelV4` from `ai/test` behind an environment va
|
|
|
370
488
|
|
|
371
489
|
dopus is pre-1.0, so expect breaking changes before 1.0. It is tested on Next.js 16 with the Node.js runtime.
|
|
372
490
|
|
|
373
|
-
Planned next:
|
|
491
|
+
Planned next, within the lightweight scope above:
|
|
374
492
|
|
|
375
|
-
-
|
|
493
|
+
- a command that updates your copies of the chat UI and route when the templates change (it also brings background
|
|
494
|
+
job support into projects created before it);
|
|
376
495
|
- tool input schemas inferred from TypeScript types.
|
|
377
496
|
|
|
378
497
|
## Development
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { n as PROVIDERS, t as VERSION } from "./version-
|
|
2
|
+
import { n as PROVIDERS, t as VERSION } from "./version-Dm8IBEUF.js";
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import * as p from "@clack/prompts";
|
package/dist/index.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ interface ToolOptions<SCHEMA extends z.ZodType, RESULT> {
|
|
|
60
60
|
confirm?: ConfirmOption<z.infer<SCHEMA>>;
|
|
61
61
|
/** Map the function's return value before it is sent to the model (trim fields, hide secrets). */
|
|
62
62
|
output?: (result: Awaited<RESULT>) => unknown;
|
|
63
|
+
/**
|
|
64
|
+
* Marks a tool that starts a background job and returns at once (hands work to a queue, a Lambda, ...).
|
|
65
|
+
* Return the job's ID from the function's result. The chat UI waits for that ID on its `channel`; when
|
|
66
|
+
* the job finishes the agent is told, reads the result with another of your tools, and continues.
|
|
67
|
+
*/
|
|
68
|
+
job?: (result: Awaited<RESULT>) => string;
|
|
63
69
|
}
|
|
64
70
|
type ToolFunction<INPUT, RESULT> = (input: INPUT, ctx: ToolContext) => RESULT;
|
|
65
71
|
interface DopusTool<SCHEMA extends z.ZodType = z.ZodType, RESULT = unknown> {
|
|
@@ -210,5 +216,5 @@ interface ProviderInfo {
|
|
|
210
216
|
}
|
|
211
217
|
/** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
|
|
212
218
|
export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
|
|
213
|
-
export declare const VERSION = "0.2.
|
|
219
|
+
export declare const VERSION = "0.2.6";
|
|
214
220
|
export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, FinishEvent, HandlerOptions, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolCallEvent, ToolContext, ToolOptions };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
|
-
import { n as PROVIDERS, t as VERSION } from "./version-
|
|
1
|
+
import { n as PROVIDERS, t as VERSION } from "./version-Dm8IBEUF.js";
|
|
2
2
|
import { convertToModelMessages, createUIMessageStreamResponse, getToolName, isStepCount, isToolUIPart, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
|
|
3
|
+
// src/job.ts
|
|
4
|
+
/**
|
|
5
|
+
* Background jobs: a tool marked with `job` starts work elsewhere (a queue, a Lambda) and returns at once.
|
|
6
|
+
* The chat UI listens on the app's channel for "job <id> finished", then sends a notice part back. The
|
|
7
|
+
* notice carries only the job ID; the agent reads the result with one of the app's tools, which checks the
|
|
8
|
+
* user's permissions. Nothing the channel delivers reaches the model as text.
|
|
9
|
+
*/
|
|
10
|
+
/** Key added to a job tool's output; the chat UI reads it to know which jobs this conversation started. */
|
|
11
|
+
const JOB_OUTPUT_KEY = "dopusJob";
|
|
12
|
+
const JOB_ID = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
13
|
+
function isJobId(value) {
|
|
14
|
+
return typeof value === "string" && JOB_ID.test(value);
|
|
15
|
+
}
|
|
16
|
+
const JOB_USAGE = "This starts a background job and returns at once. The app tells you in this chat when the job finishes; do not check on it unless the user asks.";
|
|
17
|
+
function withJob(output, id) {
|
|
18
|
+
return typeof output === "object" && output !== null && !Array.isArray(output) ? {
|
|
19
|
+
...output,
|
|
20
|
+
[JOB_OUTPUT_KEY]: id
|
|
21
|
+
} : {
|
|
22
|
+
result: output,
|
|
23
|
+
[JOB_OUTPUT_KEY]: id
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Server-written text for a job notice. Only the validated ID comes from the client. */
|
|
27
|
+
function jobNotice(id) {
|
|
28
|
+
return `[Background job ${id} has finished. This notice comes from the app, not from the user. Read the job's result with your tools, then continue with what the user asked, and tell the user briefly what came of it.]`;
|
|
29
|
+
}
|
|
3
30
|
// src/next-errors.ts
|
|
4
31
|
/**
|
|
5
32
|
* An error whose message is safe to show to the model (and, through it, the user).
|
|
@@ -110,7 +137,8 @@ function isDopusTool(value) {
|
|
|
110
137
|
function toolDescription(options) {
|
|
111
138
|
const description = options.description.trim();
|
|
112
139
|
const usage = options.usage?.trim();
|
|
113
|
-
|
|
140
|
+
const text = usage ? `${description}\n\nWhen to use: ${usage}` : description;
|
|
141
|
+
return options.job ? `${text}\n\n${JOB_USAGE}` : text;
|
|
114
142
|
}
|
|
115
143
|
// src/agent.ts
|
|
116
144
|
const KEEP_ALIVE = new TextEncoder().encode(": keep-alive\n\n");
|
|
@@ -198,7 +226,13 @@ function buildTools(tools, ctx) {
|
|
|
198
226
|
toolCallId,
|
|
199
227
|
abortSignal
|
|
200
228
|
});
|
|
201
|
-
const
|
|
229
|
+
const mapped = options.output ? options.output(result) : result;
|
|
230
|
+
let output = mapped;
|
|
231
|
+
if (options.job) {
|
|
232
|
+
const jobId = options.job(result);
|
|
233
|
+
if (!isJobId(jobId)) throw new TypeError(`tools.${name}: job() must return an ID matching [A-Za-z0-9_.:-]{1,128}`);
|
|
234
|
+
output = withJob(mapped, jobId);
|
|
235
|
+
}
|
|
202
236
|
await runHook(ctx.onToolCall, {
|
|
203
237
|
...base,
|
|
204
238
|
outcome: "success",
|
|
@@ -284,6 +318,7 @@ function createAgentHandler(config, options = {}) {
|
|
|
284
318
|
if (!validated.success) return errorResponse(400, "Invalid messages");
|
|
285
319
|
const messages = validated.data;
|
|
286
320
|
if (messages.some((message) => message.role === "user" && message.parts.some((part) => part.type === "text" && part.text.length > maxMessageChars))) return errorResponse(413, "Message is too long");
|
|
321
|
+
if (messages.some((message) => message.parts.some((part) => part.type === "data-dopus-job" && !isJobId(part.data?.id)))) return errorResponse(400, "Invalid job notice");
|
|
287
322
|
const early = await config.beforeRequest?.({
|
|
288
323
|
request,
|
|
289
324
|
user,
|
|
@@ -313,7 +348,13 @@ function createAgentHandler(config, options = {}) {
|
|
|
313
348
|
...config.callOptions,
|
|
314
349
|
model: config.model,
|
|
315
350
|
instructions: buildInstructions(config.prompt, runtime),
|
|
316
|
-
messages: await convertToModelMessages(messages, {
|
|
351
|
+
messages: await convertToModelMessages(messages, {
|
|
352
|
+
tools,
|
|
353
|
+
convertDataPart: (part) => part.type === "data-dopus-job" ? {
|
|
354
|
+
type: "text",
|
|
355
|
+
text: jobNotice(part.data.id)
|
|
356
|
+
} : void 0
|
|
357
|
+
}),
|
|
317
358
|
tools,
|
|
318
359
|
toolApproval,
|
|
319
360
|
stopWhen: isStepCount(maxSteps),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dopus",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.6",
|
|
4
|
+
"description": "A lightweight agent for Next.js: your existing server functions become its tools, run with the signed-in user's permissions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "akodm",
|
|
7
7
|
"homepage": "https://github.com/akodm/dopus#readme",
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
"openai",
|
|
25
25
|
"anthropic",
|
|
26
26
|
"gemini",
|
|
27
|
-
"chatbot"
|
|
27
|
+
"chatbot",
|
|
28
|
+
"lightweight",
|
|
29
|
+
"app-router",
|
|
30
|
+
"tools"
|
|
28
31
|
],
|
|
29
32
|
"type": "module",
|
|
30
33
|
"engines": {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Chat UI for the dopus agent. Copied by `dopus init`; this file is yours to edit.
|
|
5
5
|
* Talks to POST /api/agent with the AI SDK `useChat` protocol.
|
|
6
6
|
* Styles: ./agent-chat.css — plain global CSS with `dopus-` class names (works with or without Tailwind).
|
|
7
|
+
* Background jobs: pass a `channel` and the agent continues by itself when a job it started finishes.
|
|
7
8
|
*/
|
|
8
9
|
import { useChat } from '@ai-sdk/react'
|
|
9
10
|
import {
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
type FormEvent,
|
|
20
21
|
type KeyboardEvent,
|
|
21
22
|
useEffect,
|
|
23
|
+
useMemo,
|
|
22
24
|
useRef,
|
|
23
25
|
useState,
|
|
24
26
|
} from 'react'
|
|
@@ -44,6 +46,8 @@ const DEFAULT_LABELS = {
|
|
|
44
46
|
declined: 'Declined',
|
|
45
47
|
failed: 'Failed',
|
|
46
48
|
details: 'Details',
|
|
49
|
+
jobRunning: 'Working in the background',
|
|
50
|
+
jobDone: 'Background task finished',
|
|
47
51
|
open: 'Open assistant',
|
|
48
52
|
close: 'Close assistant',
|
|
49
53
|
}
|
|
@@ -57,6 +61,35 @@ export interface AgentChatProps {
|
|
|
57
61
|
/** Example prompts shown before the first message. */
|
|
58
62
|
suggestions?: string[]
|
|
59
63
|
className?: string
|
|
64
|
+
/**
|
|
65
|
+
* Where the app announces finished background jobs (Supabase Realtime, a WebSocket, SSE, polling...).
|
|
66
|
+
* When a job started in this chat finishes, the agent is told and continues by itself. Create the
|
|
67
|
+
* channel once (module scope or `useMemo`) in a client component; it holds functions, so it cannot be
|
|
68
|
+
* passed from a server component.
|
|
69
|
+
*/
|
|
70
|
+
channel?: AgentChannel
|
|
71
|
+
/** Follow-up turns the agent may take in a row on finished jobs before waiting for the user. Default 3. */
|
|
72
|
+
maxJobFollowUps?: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AgentChannel {
|
|
76
|
+
/** Start listening and call `finished(jobId)` for each job that finishes. Return a function that stops. */
|
|
77
|
+
subscribe: (finished: (jobId: string) => void) => () => void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Must match the dopus server: a job tool's output carries `dopusJob: <id>`, and a finished job is reported
|
|
81
|
+
// back as a `data-dopus-job` part. The part carries only the ID; the server writes the text the model sees.
|
|
82
|
+
const JOB_KEY = 'dopusJob'
|
|
83
|
+
const JOB_PART = 'data-dopus-job'
|
|
84
|
+
|
|
85
|
+
function startedJob(part: UIMessage['parts'][number]): string | undefined {
|
|
86
|
+
if (!isToolUIPart(part) || part.state !== 'output-available') return undefined
|
|
87
|
+
const id = (part.output as Record<string, unknown> | null)?.[JOB_KEY]
|
|
88
|
+
return typeof id === 'string' ? id : undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isJobNotice(message: UIMessage) {
|
|
92
|
+
return message.role === 'user' && message.parts.every((part) => part.type === JOB_PART)
|
|
60
93
|
}
|
|
61
94
|
|
|
62
95
|
type ToolPart = Extract<UIMessage['parts'][number], { toolCallId: string }>
|
|
@@ -78,12 +111,21 @@ function ToolCall({
|
|
|
78
111
|
part,
|
|
79
112
|
labels,
|
|
80
113
|
onRespond,
|
|
114
|
+
waiting,
|
|
81
115
|
}: {
|
|
82
116
|
part: ToolPart
|
|
83
117
|
labels: AgentChatLabels
|
|
84
118
|
onRespond: (approvalId: string, approved: boolean) => void
|
|
119
|
+
waiting: boolean
|
|
85
120
|
}) {
|
|
86
121
|
const name = humanize(getToolName(part))
|
|
122
|
+
if (waiting) {
|
|
123
|
+
return (
|
|
124
|
+
<div className="dopus-tool" data-state="running">
|
|
125
|
+
<span className="dopus-spinner" aria-hidden /> {labels.jobRunning}: {name}
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
87
129
|
|
|
88
130
|
switch (part.state) {
|
|
89
131
|
case 'input-streaming':
|
|
@@ -187,11 +229,16 @@ function Message({
|
|
|
187
229
|
message,
|
|
188
230
|
labels,
|
|
189
231
|
onRespond,
|
|
232
|
+
waitingJobs,
|
|
190
233
|
}: {
|
|
191
234
|
message: UIMessage
|
|
192
235
|
labels: AgentChatLabels
|
|
193
236
|
onRespond: (approvalId: string, approved: boolean) => void
|
|
237
|
+
waitingJobs: Set<string>
|
|
194
238
|
}) {
|
|
239
|
+
if (isJobNotice(message)) {
|
|
240
|
+
return <div className="dopus-notice">✓ {labels.jobDone}</div>
|
|
241
|
+
}
|
|
195
242
|
return (
|
|
196
243
|
<div className="dopus-message" data-role={message.role}>
|
|
197
244
|
{message.parts.map((part, index) => {
|
|
@@ -207,7 +254,16 @@ function Message({
|
|
|
207
254
|
)
|
|
208
255
|
}
|
|
209
256
|
if (isToolUIPart(part)) {
|
|
210
|
-
|
|
257
|
+
const job = startedJob(part)
|
|
258
|
+
return (
|
|
259
|
+
<ToolCall
|
|
260
|
+
key={key}
|
|
261
|
+
part={part}
|
|
262
|
+
labels={labels}
|
|
263
|
+
onRespond={onRespond}
|
|
264
|
+
waiting={job !== undefined && waitingJobs.has(job)}
|
|
265
|
+
/>
|
|
266
|
+
)
|
|
211
267
|
}
|
|
212
268
|
return null
|
|
213
269
|
})}
|
|
@@ -220,6 +276,8 @@ export function AgentChat({
|
|
|
220
276
|
labels: labelOverrides,
|
|
221
277
|
suggestions = [],
|
|
222
278
|
className,
|
|
279
|
+
channel,
|
|
280
|
+
maxJobFollowUps = 3,
|
|
223
281
|
}: AgentChatProps) {
|
|
224
282
|
const labels = { ...DEFAULT_LABELS, ...labelOverrides }
|
|
225
283
|
const router = useRouter()
|
|
@@ -256,6 +314,55 @@ export function AgentChat({
|
|
|
256
314
|
const listRef = useRef<HTMLDivElement>(null)
|
|
257
315
|
const busy = status === 'submitted' || status === 'streaming'
|
|
258
316
|
|
|
317
|
+
// Jobs the channel reported finished. Kept even before this chat shows the job (a job can finish before
|
|
318
|
+
// its tool output has streamed in); only IDs this chat started are acted on.
|
|
319
|
+
const [finishedJobs, setFinishedJobs] = useState<Set<string>>(() => new Set())
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
if (!channel) return
|
|
322
|
+
return channel.subscribe((id) => setFinishedJobs((prev) => new Set(prev).add(id)))
|
|
323
|
+
}, [channel])
|
|
324
|
+
|
|
325
|
+
// Derived from the messages, so a new chat (or another tab) never reacts to jobs it did not start.
|
|
326
|
+
const { waitingJobs, readyJobs } = useMemo(() => {
|
|
327
|
+
const started = new Set<string>()
|
|
328
|
+
const reported = new Set<string>()
|
|
329
|
+
for (const message of messages) {
|
|
330
|
+
for (const part of message.parts) {
|
|
331
|
+
const id = startedJob(part)
|
|
332
|
+
if (id) started.add(id)
|
|
333
|
+
if (part.type === JOB_PART) reported.add((part.data as { id: string }).id)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const waiting = new Set([...started].filter((id) => !reported.has(id)))
|
|
337
|
+
return { waitingJobs: waiting, readyJobs: [...waiting].filter((id) => finishedJobs.has(id)) }
|
|
338
|
+
}, [messages, finishedJobs])
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (readyJobs.length === 0 || status !== 'ready') return
|
|
342
|
+
const last = messages.at(-1)
|
|
343
|
+
// Wait while an approval card is open: the user answers that first.
|
|
344
|
+
if (last?.parts.some((part) => isToolUIPart(part) && part.state === 'approval-requested'))
|
|
345
|
+
return
|
|
346
|
+
const notice = {
|
|
347
|
+
role: 'user' as const,
|
|
348
|
+
parts: readyJobs.map(
|
|
349
|
+
(id) => ({ type: JOB_PART, data: { id } }) as UIMessage['parts'][number],
|
|
350
|
+
),
|
|
351
|
+
}
|
|
352
|
+
// Follow-ups since the user last wrote; past the cap, the notice waits in the history for the user's next message.
|
|
353
|
+
let followUps = 0
|
|
354
|
+
for (const message of [...messages].reverse()) {
|
|
355
|
+
if (message.role !== 'user') continue
|
|
356
|
+
if (!isJobNotice(message)) break
|
|
357
|
+
followUps++
|
|
358
|
+
}
|
|
359
|
+
if (followUps >= maxJobFollowUps) {
|
|
360
|
+
setMessages([...messages, { id: `job-${readyJobs.join('-')}`, ...notice }])
|
|
361
|
+
} else {
|
|
362
|
+
sendMessage(notice)
|
|
363
|
+
}
|
|
364
|
+
}, [readyJobs, status, messages, maxJobFollowUps, sendMessage, setMessages])
|
|
365
|
+
|
|
259
366
|
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll whenever messages change
|
|
260
367
|
useEffect(() => {
|
|
261
368
|
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' })
|
|
@@ -322,7 +429,13 @@ export function AgentChat({
|
|
|
322
429
|
</div>
|
|
323
430
|
)}
|
|
324
431
|
{messages.map((message) => (
|
|
325
|
-
<Message
|
|
432
|
+
<Message
|
|
433
|
+
key={message.id}
|
|
434
|
+
message={message}
|
|
435
|
+
labels={labels}
|
|
436
|
+
onRespond={respond}
|
|
437
|
+
waitingJobs={waitingJobs}
|
|
438
|
+
/>
|
|
326
439
|
))}
|
|
327
440
|
{status === 'submitted' && <div className="dopus-thinking">{labels.thinking}</div>}
|
|
328
441
|
{error && (
|