dopus 0.2.5 → 0.2.7
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 +148 -7
- package/dist/cli.js +304 -2
- package/dist/index.d.ts +7 -1
- package/dist/index.js +45 -4
- package/dist/{version-DmrGrSmM.js → version-DqzDElrt.js} +1 -1
- package/package.json +6 -3
- package/templates/history.json +35 -0
- package/templates/ui/agent-chat.css +21 -0
- package/templates/ui/agent-chat.tsx +143 -5
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
|
|
@@ -67,6 +83,27 @@ dopus init [--provider openai|anthropic|google] [--model <id>]
|
|
|
67
83
|
|
|
68
84
|
`--yes` runs without prompts. It needs `--provider` and skips the API key prompt, so add the key to `.env.local` yourself afterwards.
|
|
69
85
|
|
|
86
|
+
### Updating
|
|
87
|
+
|
|
88
|
+
`init` copies files into your project, so a new dopus version does not change them by itself. After upgrading the
|
|
89
|
+
package, bring them up to date:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pnpm add dopus@latest
|
|
93
|
+
pnpm dopus update # --dry-run to only see what would change
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`update` handles the files `init` copied: the chat UI (`agent-chat.tsx`, `agent-chat.css`), the route and the preset
|
|
97
|
+
prompt. It never touches `agent.config.ts`, `agent/prompts/app.ts` or `.env.local`.
|
|
98
|
+
|
|
99
|
+
- A file you never edited (it matches a template some dopus version shipped) is replaced.
|
|
100
|
+
- A file you edited is never replaced silently. You choose: see the diff, keep yours and get the new template next to
|
|
101
|
+
it as `<file>.new` to merge by hand, replace it, or skip. `--yes` always takes the `.new` route; `--force` replaces.
|
|
102
|
+
- Packages the new chat UI needs are installed (`--no-install` prints the command instead).
|
|
103
|
+
|
|
104
|
+
Commit first, so `git diff` shows exactly what changed. The [changelog](CHANGELOG.md) says what each version changed
|
|
105
|
+
and anything to do in `agent.config.ts`.
|
|
106
|
+
|
|
70
107
|
## Tools
|
|
71
108
|
|
|
72
109
|
```ts
|
|
@@ -108,6 +145,20 @@ export default defineAgent({
|
|
|
108
145
|
|
|
109
146
|
`ctx` is `{ user, toolCallId, abortSignal }`. `user` is only set when you configure `identify` (see below).
|
|
110
147
|
|
|
148
|
+
### Designing tools
|
|
149
|
+
|
|
150
|
+
A lightweight agent works best with small, fast tools:
|
|
151
|
+
|
|
152
|
+
- **Few.** Register the tools a user needs here, not every function you have. Past roughly 20 to 30 tools, models pick
|
|
153
|
+
the wrong one more often, and every request carries every description.
|
|
154
|
+
- **Fast.** A tool should return in a second or two. Longer work belongs in a job (see
|
|
155
|
+
[Timeouts and long-running tools](#timeouts-and-long-running-tools)).
|
|
156
|
+
- **Small results.** Take filters and a limit, and return only the fields the agent needs (`output` helps). A thousand
|
|
157
|
+
rows cost tokens on every later step, and the whole history counts against `limits.maxBodyBytes`.
|
|
158
|
+
- **Batch, don't loop.** "Close all 200 stale tickets" should be one server function that takes a filter, not 200 calls.
|
|
159
|
+
A turn allows at most `maxSteps` (10) model ↔ tool round trips.
|
|
160
|
+
- **Clear names and descriptions.** The model chooses by them. Say when _not_ to use a tool in `usage`.
|
|
161
|
+
|
|
111
162
|
### Errors
|
|
112
163
|
|
|
113
164
|
Only two kinds of error message reach the model:
|
|
@@ -151,7 +202,7 @@ Your part, so an injected instruction cannot cause damage:
|
|
|
151
202
|
|
|
152
203
|
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
204
|
|
|
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.
|
|
205
|
+
**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
206
|
|
|
156
207
|
## Prompts
|
|
157
208
|
|
|
@@ -173,9 +224,12 @@ Anthropic gets an explicit cache breakpoint after layer 3. OpenAI and Gemini cac
|
|
|
173
224
|
- `<AgentChat />`: the panel;
|
|
174
225
|
- `<AgentWidget />`: a floating launcher that opens the panel.
|
|
175
226
|
|
|
176
|
-
Both accept `api`, `suggestions`, `labels` (every UI string, for translation) and `
|
|
227
|
+
Both accept `api`, `suggestions`, `labels` (every UI string, for translation), `className`, and `channel` /
|
|
228
|
+
`maxJobFollowUps` for [background jobs](#background-jobs).
|
|
177
229
|
|
|
178
|
-
The UI shows each tool call as it runs.
|
|
230
|
+
The UI shows each tool call as it runs. When a reply finishes while the widget is closed (for example the agent
|
|
231
|
+
continuing after a [background job](#background-jobs)), the launcher shows a dot until it is opened (`labels.unread`
|
|
232
|
+
for screen readers; `onReply` if you want your own signal). 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
233
|
|
|
180
234
|
Styling is one plain CSS file (`agent-chat.css`) whose classes all start with `dopus-`, themed through `--dopus-*` variables, with dark mode included. It is deliberately not a CSS Module: create-next-app's Tailwind setup routes every `*.css` file, modules included, through its own Turbopack loader, which left module classes unstyled. It needs no Tailwind and no component library. The file is yours, so restyle it or swap in your own components.
|
|
181
235
|
|
|
@@ -288,11 +342,98 @@ The job runs in your queue or background worker, outside the request, so it does
|
|
|
288
342
|
permissions in `startExportJob` and pass the user to the job explicitly (see `identify` and `ctx.user`). Have `getExportJob`
|
|
289
343
|
check that the job belongs to the caller.
|
|
290
344
|
|
|
345
|
+
With a status tool alone, the user has to ask "is it done?". To have the agent continue by itself when the job finishes,
|
|
346
|
+
mark the start tool with `job` and give the chat UI a channel: see [Background jobs](#background-jobs).
|
|
347
|
+
|
|
291
348
|
**If a turn is cut anyway**, tools that already ran stay done. "Retry" asks the model again and may call a tool again.
|
|
292
349
|
Tools behind `confirm: true` need a new approval; make other writes idempotent.
|
|
293
350
|
|
|
351
|
+
## Background jobs
|
|
352
|
+
|
|
353
|
+
For work that takes longer than a request: a Lambda that calls an LLM for a minute, a report, a bulk import. The tool
|
|
354
|
+
starts the job and returns at once; your worker does the work; your app announces "job X finished" on a channel it
|
|
355
|
+
already has (Supabase Realtime, a WebSocket, SSE, polling). The chat UI hears it and the agent continues: it reads the
|
|
356
|
+
result with another of your tools and does what the user asked next.
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
user: "build the workload report, then assign unassigned tasks to whoever is least busy"
|
|
360
|
+
→ startWorkloadReport returns { jobId } at once; the agent says it started, the turn ends
|
|
361
|
+
… your worker runs for a minute, saves the result, publishes "job-7 finished" on your channel …
|
|
362
|
+
→ the chat UI hears job-7 sends a notice carrying only the ID; a new turn starts
|
|
363
|
+
→ getReportResult({ jobId }) your tool reads the result and checks it belongs to the user
|
|
364
|
+
→ assignTask(...) confirmed tools still stop at the approval card
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**Server**: mark the tool that starts the job, and add a tool that reads the result.
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
startReport: defineTool(() => startReportJob(), { // invoke the Lambda / enqueue, return { jobId } at once
|
|
371
|
+
description: 'Starts the workload report. Takes about a minute.',
|
|
372
|
+
input: z.object({}),
|
|
373
|
+
job: (result) => result.jobId, // this tool starts a background job
|
|
374
|
+
}),
|
|
375
|
+
getReportResult: defineTool((input) => readReport(input.jobId), { // check the job belongs to the caller
|
|
376
|
+
description: 'Reads a report job by its id: status and, when done, the report.',
|
|
377
|
+
usage: 'After the app says a report job has finished.',
|
|
378
|
+
input: z.object({ jobId: z.string() }),
|
|
379
|
+
}),
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`job` returns the job's ID (letters, digits, `_ . : -`, up to 128 characters). dopus tells the model the job runs in
|
|
383
|
+
the background and that it will be told when it finishes, so it does not poll.
|
|
384
|
+
|
|
385
|
+
**Client**: give the chat a `channel`. It holds functions, so create it in a client component, once:
|
|
386
|
+
|
|
387
|
+
```tsx
|
|
388
|
+
// components/agent/agent.tsx
|
|
389
|
+
'use client'
|
|
390
|
+
import { type AgentChannel, type AgentChatProps, AgentWidget } from './agent-chat'
|
|
391
|
+
import { supabase } from '@/lib/supabase/client'
|
|
392
|
+
|
|
393
|
+
const channel: AgentChannel = {
|
|
394
|
+
subscribe(finished) {
|
|
395
|
+
const ch = supabase
|
|
396
|
+
.channel('jobs', { config: { private: true } })
|
|
397
|
+
.on('broadcast', { event: 'job-done' }, ({ payload }) => finished(payload.jobId))
|
|
398
|
+
.subscribe()
|
|
399
|
+
return () => {
|
|
400
|
+
supabase.removeChannel(ch)
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function Agent(props: AgentChatProps) {
|
|
406
|
+
return <AgentWidget {...props} channel={channel} />
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Any source works: `new EventSource(url)`, a WebSocket, or a `setInterval` that asks your API which jobs finished. Call
|
|
411
|
+
`finished(jobId)` for each one; return a function that stops listening. Then use `<Agent />` in your layout instead of
|
|
412
|
+
`<AgentWidget />`.
|
|
413
|
+
|
|
414
|
+
**How it is kept safe:**
|
|
415
|
+
|
|
416
|
+
- **The channel is a doorbell, not a mailbox.** Only the job ID goes from the channel to the server. The text the model
|
|
417
|
+
sees ("Background job X has finished…") is written by the server, and the result comes from your tool, which checks
|
|
418
|
+
permissions. A forged or replayed event can at most make the agent look up a job the user can already read.
|
|
419
|
+
- **Only this chat's jobs.** The UI reacts only to IDs that a tool in this conversation returned and that it has not
|
|
420
|
+
reported yet. Another tab, or a new chat, ignores them.
|
|
421
|
+
- **Follow-ups are capped.** After `maxJobFollowUps` (default 3) automatic turns in a row, a finished job is shown in
|
|
422
|
+
the chat but the agent waits for the user's next message, so a job that starts another job cannot loop.
|
|
423
|
+
- **Approvals still apply.** An automatic turn stops at the approval card like any other.
|
|
424
|
+
|
|
425
|
+
**Limits:**
|
|
426
|
+
|
|
427
|
+
- 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
|
|
428
|
+
app (show it there); the agent just does not act on it. Work that must continue without the user belongs in your
|
|
429
|
+
worker, not in the agent.
|
|
430
|
+
- Each finished job costs one more model call.
|
|
431
|
+
|
|
294
432
|
## Deploying
|
|
295
433
|
|
|
434
|
+
The `dopus infra` commands write the deploy configuration that keeps the agent route streaming within each platform's
|
|
435
|
+
limits. The agent still runs inside your Next.js app; nothing is moved elsewhere.
|
|
436
|
+
|
|
296
437
|
**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
438
|
|
|
298
439
|
**AWS (OpenNext / SST).**
|
|
@@ -370,9 +511,8 @@ Swap in a scripted `MockLanguageModelV4` from `ai/test` behind an environment va
|
|
|
370
511
|
|
|
371
512
|
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
513
|
|
|
373
|
-
Planned next:
|
|
514
|
+
Planned next, within the lightweight scope above:
|
|
374
515
|
|
|
375
|
-
- `dopus prompts diff` to compare your copies with newer templates;
|
|
376
516
|
- tool input schemas inferred from TypeScript types.
|
|
377
517
|
|
|
378
518
|
## Development
|
|
@@ -382,7 +522,8 @@ pnpm install
|
|
|
382
522
|
pnpm build && pnpm lint && pnpm typecheck && pnpm test
|
|
383
523
|
pnpm example:build && pnpm example:e2e # next start + scripted model
|
|
384
524
|
pnpm example:live # real model from examples/next-app/.env.local (costs cents)
|
|
385
|
-
pnpm smoke # packed tarball → fresh app →
|
|
525
|
+
pnpm smoke # packed tarball → fresh app → init (older release) → update → next build
|
|
526
|
+
pnpm templates:history # after changing a template: record its hash for `dopus update`
|
|
386
527
|
pnpm aws:emu # OpenNext build → local Lambda streaming invoke
|
|
387
528
|
pnpm cf:e2e # dopus infra cloudflare → wrangler dev (workerd) → e2e
|
|
388
529
|
pnpm gcp:e2e # dopus infra gcp → docker build/run (Cloud Run style) → e2e
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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-DqzDElrt.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";
|
|
6
6
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { dirname, join, relative, resolve } from "node:path";
|
|
8
|
-
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
// src/init/detect.ts
|
|
11
11
|
var InitError = class extends Error {
|
|
@@ -808,11 +808,249 @@ async function runInfraGcp(options, io) {
|
|
|
808
808
|
].join("\n"), "Cloud Run notes");
|
|
809
809
|
return result;
|
|
810
810
|
}
|
|
811
|
+
// src/update/diff.ts
|
|
812
|
+
/** Line diff (LCS) in unified style with 3 lines of context. Small and dependency-free; templates are short. */
|
|
813
|
+
function unifiedDiff(before, after, context = 3) {
|
|
814
|
+
const a = before.replace(/\r\n/g, "\n").split("\n");
|
|
815
|
+
const b = after.replace(/\r\n/g, "\n").split("\n");
|
|
816
|
+
const n = a.length;
|
|
817
|
+
const m = b.length;
|
|
818
|
+
if (n * m > 4e6) return "(files too large to diff here)";
|
|
819
|
+
const table = new Uint32Array((n + 1) * (m + 1));
|
|
820
|
+
const lcs = (i, j) => table[i * (m + 1) + j] ?? 0;
|
|
821
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) table[i * (m + 1) + j] = a[i] === b[j] ? lcs(i + 1, j + 1) + 1 : Math.max(lcs(i + 1, j), lcs(i, j + 1));
|
|
822
|
+
const lines = [];
|
|
823
|
+
let i = 0;
|
|
824
|
+
let j = 0;
|
|
825
|
+
while (i < n || j < m) if (i < n && j < m && a[i] === b[j]) {
|
|
826
|
+
lines.push({
|
|
827
|
+
op: " ",
|
|
828
|
+
text: a[i] ?? ""
|
|
829
|
+
});
|
|
830
|
+
i++;
|
|
831
|
+
j++;
|
|
832
|
+
} else if (i < n && (j >= m || lcs(i + 1, j) >= lcs(i, j + 1))) {
|
|
833
|
+
lines.push({
|
|
834
|
+
op: "-",
|
|
835
|
+
text: a[i] ?? ""
|
|
836
|
+
});
|
|
837
|
+
i++;
|
|
838
|
+
} else {
|
|
839
|
+
lines.push({
|
|
840
|
+
op: "+",
|
|
841
|
+
text: b[j] ?? ""
|
|
842
|
+
});
|
|
843
|
+
j++;
|
|
844
|
+
}
|
|
845
|
+
const changed = [];
|
|
846
|
+
lines.forEach((line, index) => {
|
|
847
|
+
if (line.op !== " ") changed.push(index);
|
|
848
|
+
});
|
|
849
|
+
const out = [];
|
|
850
|
+
let start = 0;
|
|
851
|
+
while (start < changed.length) {
|
|
852
|
+
let end = start;
|
|
853
|
+
while (end + 1 < changed.length && (changed[end + 1] ?? 0) - (changed[end] ?? 0) <= context * 2) end++;
|
|
854
|
+
const from = Math.max(0, (changed[start] ?? 0) - context);
|
|
855
|
+
const to = Math.min(lines.length - 1, (changed[end] ?? 0) + context);
|
|
856
|
+
out.push("@@");
|
|
857
|
+
for (const line of lines.slice(from, to + 1)) out.push(`${line.op}${line.text}`);
|
|
858
|
+
start = end + 1;
|
|
859
|
+
}
|
|
860
|
+
return out.join("\n");
|
|
861
|
+
}
|
|
862
|
+
// src/update/history.ts
|
|
863
|
+
/** The generated route imports the agent config by a path that differs per project. */
|
|
864
|
+
const CONFIG_IMPORT = /^import agent from ['"]([^'"]+)['"]/m;
|
|
865
|
+
/** Must match normalize() in scripts/template-history.mjs. */
|
|
866
|
+
function normalize(text) {
|
|
867
|
+
return text.replace(/\r\n/g, "\n").replace(CONFIG_IMPORT, "import agent from '<config>'");
|
|
868
|
+
}
|
|
869
|
+
function templateHash(text) {
|
|
870
|
+
return createHash("sha256").update(normalize(text)).digest("hex").slice(0, 16);
|
|
871
|
+
}
|
|
872
|
+
let cached;
|
|
873
|
+
function templateHistory() {
|
|
874
|
+
cached ??= JSON.parse(readTemplate("history.json"));
|
|
875
|
+
return cached;
|
|
876
|
+
}
|
|
877
|
+
/** The dopus version whose untouched template this is, or undefined if the user changed it. */
|
|
878
|
+
function shippedIn(key, text) {
|
|
879
|
+
return templateHistory()[key]?.[templateHash(text)];
|
|
880
|
+
}
|
|
881
|
+
// src/update/run.ts
|
|
882
|
+
/** Packages the chat UI imports (the rest were added by `init` and are needed by the route too). */
|
|
883
|
+
const UI_DEPENDENCIES = [
|
|
884
|
+
["@ai-sdk/react", "@ai-sdk/react@^4"],
|
|
885
|
+
["react-markdown", "react-markdown@^10"],
|
|
886
|
+
["remark-gfm", "remark-gfm@^4"]
|
|
887
|
+
];
|
|
888
|
+
/** Which preset an existing preset.ts came from: by its hash, else the one it shares most lines with. */
|
|
889
|
+
function presetKey(text) {
|
|
890
|
+
const keys = PRESETS.map((preset) => `prompts/presets/${preset.id}.ts`);
|
|
891
|
+
const exact = keys.find((key) => shippedIn(key, text));
|
|
892
|
+
if (exact) return exact;
|
|
893
|
+
const lines = new Set(text.split("\n").map((line) => line.trim()));
|
|
894
|
+
const shared = (key) => readTemplate(key).split("\n").filter((line) => line.trim() && lines.has(line.trim())).length;
|
|
895
|
+
return keys.reduce((best, key) => shared(key) > shared(best) ? key : best);
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Brings the files `dopus init` copied (chat UI, route, preset prompt) up to this version's templates.
|
|
899
|
+
* Untouched copies of an older version are replaced; copies the user edited are never replaced without
|
|
900
|
+
* asking. agent.config.ts, app.ts and .env.local are the user's and are never touched.
|
|
901
|
+
*/
|
|
902
|
+
async function runUpdate(options, io) {
|
|
903
|
+
const project = detectProject(options.cwd);
|
|
904
|
+
const { root } = project;
|
|
905
|
+
const at = (path) => join(project.configDir, path);
|
|
906
|
+
const read = (file) => readFileSync(join(root, file), "utf8");
|
|
907
|
+
const exists = (file) => existsSync(join(root, file));
|
|
908
|
+
const result = {
|
|
909
|
+
files: [],
|
|
910
|
+
installed: [],
|
|
911
|
+
missingDependencies: []
|
|
912
|
+
};
|
|
913
|
+
const chatFile = at("components/agent/agent-chat.tsx");
|
|
914
|
+
const routeFile = join(project.appDir, "api", "agent", "route.ts");
|
|
915
|
+
const presetFile = at("agent/prompts/preset.ts");
|
|
916
|
+
const targets = [{
|
|
917
|
+
file: chatFile,
|
|
918
|
+
key: "ui/agent-chat.tsx",
|
|
919
|
+
latest: readTemplate("ui/agent-chat.tsx")
|
|
920
|
+
}, {
|
|
921
|
+
file: at("components/agent/agent-chat.css"),
|
|
922
|
+
key: "ui/agent-chat.css",
|
|
923
|
+
latest: readTemplate("ui/agent-chat.css"),
|
|
924
|
+
create: exists(chatFile)
|
|
925
|
+
}];
|
|
926
|
+
if (exists(routeFile)) {
|
|
927
|
+
const configImport = read(routeFile).match(CONFIG_IMPORT)?.[1] ?? toImportPath(routeFile, at("agent.config"));
|
|
928
|
+
targets.push({
|
|
929
|
+
file: routeFile,
|
|
930
|
+
key: "route",
|
|
931
|
+
latest: routeTemplate(configImport)
|
|
932
|
+
});
|
|
933
|
+
} else result.files.push({
|
|
934
|
+
file: routeFile,
|
|
935
|
+
status: "missing"
|
|
936
|
+
});
|
|
937
|
+
if (exists(presetFile)) {
|
|
938
|
+
const key = presetKey(read(presetFile));
|
|
939
|
+
targets.push({
|
|
940
|
+
file: presetFile,
|
|
941
|
+
key,
|
|
942
|
+
latest: readTemplate(key)
|
|
943
|
+
});
|
|
944
|
+
} else result.files.push({
|
|
945
|
+
file: presetFile,
|
|
946
|
+
status: "missing"
|
|
947
|
+
});
|
|
948
|
+
const write = (file, content) => {
|
|
949
|
+
if (!options.dryRun) writeFileSync(join(root, file), content);
|
|
950
|
+
};
|
|
951
|
+
for (const target of targets) {
|
|
952
|
+
const { file, latest } = target;
|
|
953
|
+
if (!exists(file)) {
|
|
954
|
+
if (target.create) {
|
|
955
|
+
write(file, latest);
|
|
956
|
+
result.files.push({
|
|
957
|
+
file,
|
|
958
|
+
status: "created"
|
|
959
|
+
});
|
|
960
|
+
} else result.files.push({
|
|
961
|
+
file,
|
|
962
|
+
status: "missing"
|
|
963
|
+
});
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const current = read(file);
|
|
967
|
+
if (normalize(current) === normalize(latest)) {
|
|
968
|
+
result.files.push({
|
|
969
|
+
file,
|
|
970
|
+
status: "up-to-date"
|
|
971
|
+
});
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
const from = shippedIn(target.key, current);
|
|
975
|
+
if (from) {
|
|
976
|
+
write(file, latest);
|
|
977
|
+
result.files.push({
|
|
978
|
+
file,
|
|
979
|
+
status: "updated",
|
|
980
|
+
from
|
|
981
|
+
});
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
let choice;
|
|
985
|
+
if (options.dryRun) {
|
|
986
|
+
result.files.push({
|
|
987
|
+
file,
|
|
988
|
+
status: "modified"
|
|
989
|
+
});
|
|
990
|
+
continue;
|
|
991
|
+
} else if (options.force) choice = "overwrite";
|
|
992
|
+
else if (options.yes) choice = "new";
|
|
993
|
+
else do {
|
|
994
|
+
choice = await io.choose(`${file} has your changes. Replace it with the new template?`, [
|
|
995
|
+
{
|
|
996
|
+
value: "diff",
|
|
997
|
+
label: "Show the diff (yours → new)"
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
value: "new",
|
|
1001
|
+
label: `Keep yours, save the new one as ${file}.new`
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
value: "overwrite",
|
|
1005
|
+
label: "Replace (your changes are lost)"
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
value: "skip",
|
|
1009
|
+
label: "Keep yours, skip"
|
|
1010
|
+
}
|
|
1011
|
+
]);
|
|
1012
|
+
if (choice === "diff") io.diff(unifiedDiff(current, latest));
|
|
1013
|
+
} while (choice === "diff");
|
|
1014
|
+
if (choice === "overwrite") {
|
|
1015
|
+
write(file, latest);
|
|
1016
|
+
result.files.push({
|
|
1017
|
+
file,
|
|
1018
|
+
status: "overwritten"
|
|
1019
|
+
});
|
|
1020
|
+
} else if (choice === "new") {
|
|
1021
|
+
write(`${file}.new`, latest);
|
|
1022
|
+
result.files.push({
|
|
1023
|
+
file,
|
|
1024
|
+
status: "saved-new"
|
|
1025
|
+
});
|
|
1026
|
+
} else result.files.push({
|
|
1027
|
+
file,
|
|
1028
|
+
status: "kept"
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
const chatReplaced = result.files.some((f) => f.file === chatFile && (f.status === "updated" || f.status === "overwritten"));
|
|
1032
|
+
const oldModule = at("components/agent/agent-chat.module.css");
|
|
1033
|
+
if (chatReplaced && exists(oldModule)) io.warn(`${oldModule} is no longer used by the chat UI (it now imports agent-chat.css). Delete it.`);
|
|
1034
|
+
if (exists(chatFile)) {
|
|
1035
|
+
const missing = UI_DEPENDENCIES.filter(([name]) => !project.dependencies[name]).map(([, spec]) => spec);
|
|
1036
|
+
if (missing.length > 0) {
|
|
1037
|
+
if (!options.dryRun && options.install !== false && (options.yes || options.force || await io.confirm(`Install ${missing.join(", ")}?`, true))) {
|
|
1038
|
+
await io.install(project.packageManager, missing, root);
|
|
1039
|
+
result.installed = missing;
|
|
1040
|
+
} else {
|
|
1041
|
+
result.missingDependencies = missing;
|
|
1042
|
+
io.warn(`The chat UI needs: ${project.packageManager} ${installArgs(project.packageManager, missing).join(" ")}`);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
return result;
|
|
1047
|
+
}
|
|
811
1048
|
// src/cli.ts
|
|
812
1049
|
const HELP = `dopus ${VERSION}
|
|
813
1050
|
|
|
814
1051
|
Usage:
|
|
815
1052
|
dopus init [options] Add an AI agent to this Next.js app
|
|
1053
|
+
dopus update [options] Update the chat UI, route and preset prompt that init copied
|
|
816
1054
|
dopus infra aws [options] Prepare the agent route for AWS Lambda (OpenNext streaming + SST snippet)
|
|
817
1055
|
dopus infra cloudflare Prepare the app for Cloudflare Workers (@opennextjs/cloudflare)
|
|
818
1056
|
dopus infra gcp Prepare the app for Cloud Run (standalone build + Dockerfile)
|
|
@@ -826,6 +1064,13 @@ Options for init:
|
|
|
826
1064
|
--force Overwrite generated files that already exist
|
|
827
1065
|
--cwd <dir> Project root (default: current directory)
|
|
828
1066
|
|
|
1067
|
+
Options for update (first install the new version: pnpm add dopus@latest):
|
|
1068
|
+
--dry-run Show what would change; write nothing
|
|
1069
|
+
-y, --yes Do not ask: files you edited get the new template next to them as <file>.new
|
|
1070
|
+
--force Replace files you edited too
|
|
1071
|
+
--no-install Do not install dependencies
|
|
1072
|
+
--cwd <dir> Project root
|
|
1073
|
+
|
|
829
1074
|
Options for infra:
|
|
830
1075
|
-y, --yes Do not ask before installing @opennextjs/aws
|
|
831
1076
|
--no-install Print the install command instead
|
|
@@ -928,6 +1173,62 @@ async function init(args) {
|
|
|
928
1173
|
p.note(steps.map((s, i) => `${i + 1}. ${s}`).join("\n"), "Next steps");
|
|
929
1174
|
p.outro("Done.");
|
|
930
1175
|
}
|
|
1176
|
+
const STATUS = {
|
|
1177
|
+
"up-to-date": "up to date",
|
|
1178
|
+
updated: "updated",
|
|
1179
|
+
created: "created",
|
|
1180
|
+
modified: "has your changes: would ask (run without --dry-run)",
|
|
1181
|
+
overwritten: "replaced (your changes are gone)",
|
|
1182
|
+
kept: "kept your version",
|
|
1183
|
+
"saved-new": "kept your version; new template saved as .new next to it",
|
|
1184
|
+
missing: "not found (not created by dopus init?)"
|
|
1185
|
+
};
|
|
1186
|
+
function colorDiff(text) {
|
|
1187
|
+
if (!process.stdout.isTTY) return text;
|
|
1188
|
+
return text.split("\n").map((line) => line.startsWith("+") ? `\x1b[32m${line}\x1b[0m` : line.startsWith("-") ? `\x1b[31m${line}\x1b[0m` : line.startsWith("@@") ? `\x1b[36m${line}\x1b[0m` : line).join("\n");
|
|
1189
|
+
}
|
|
1190
|
+
async function update(args) {
|
|
1191
|
+
const { values } = parseArgs({
|
|
1192
|
+
args,
|
|
1193
|
+
options: {
|
|
1194
|
+
"dry-run": { type: "boolean" },
|
|
1195
|
+
yes: {
|
|
1196
|
+
type: "boolean",
|
|
1197
|
+
short: "y"
|
|
1198
|
+
},
|
|
1199
|
+
force: { type: "boolean" },
|
|
1200
|
+
install: {
|
|
1201
|
+
type: "boolean",
|
|
1202
|
+
default: true
|
|
1203
|
+
},
|
|
1204
|
+
"no-install": { type: "boolean" },
|
|
1205
|
+
cwd: { type: "string" }
|
|
1206
|
+
},
|
|
1207
|
+
allowNegative: true
|
|
1208
|
+
});
|
|
1209
|
+
p.intro(`dopus ${VERSION} · update${values["dry-run"] ? " (dry run)" : ""}`);
|
|
1210
|
+
p.log.info(`Templates come from the installed dopus (${VERSION}). For the newest, run \`pnpm add dopus@latest\` first.`);
|
|
1211
|
+
const updateIO = {
|
|
1212
|
+
...io,
|
|
1213
|
+
async choose(message, options) {
|
|
1214
|
+
return cancelled(await p.select({
|
|
1215
|
+
message,
|
|
1216
|
+
options
|
|
1217
|
+
}));
|
|
1218
|
+
},
|
|
1219
|
+
diff: (text) => console.log(colorDiff(text || "(no differences)"))
|
|
1220
|
+
};
|
|
1221
|
+
const result = await runUpdate({
|
|
1222
|
+
cwd: values.cwd ?? process.cwd(),
|
|
1223
|
+
dryRun: values["dry-run"],
|
|
1224
|
+
yes: values.yes,
|
|
1225
|
+
force: values.force,
|
|
1226
|
+
install: values["no-install"] ? false : values.install
|
|
1227
|
+
}, updateIO);
|
|
1228
|
+
p.note(result.files.map(({ file, status, from }) => `${file}: ${STATUS[status]}${from ? ` (was ${from})` : ""}`).join("\n"), "Files");
|
|
1229
|
+
if (result.files.some((f) => f.status === "saved-new")) p.log.info("Merge each .new file into yours, then delete it.");
|
|
1230
|
+
p.outro("What changed: https://github.com/akodm/dopus/blob/main/CHANGELOG.md");
|
|
1231
|
+
}
|
|
931
1232
|
async function infra(args) {
|
|
932
1233
|
const [target, ...rest] = args;
|
|
933
1234
|
const runners = {
|
|
@@ -970,6 +1271,7 @@ async function main() {
|
|
|
970
1271
|
if (rest.includes("--help") || rest.includes("-h")) return console.log(HELP);
|
|
971
1272
|
if (command === "init") return init(rest);
|
|
972
1273
|
if (command === "infra") return infra(rest);
|
|
1274
|
+
if (command === "update") return update(rest);
|
|
973
1275
|
console.log(HELP);
|
|
974
1276
|
if (command && command !== "--help" && command !== "-h") process.exitCode = 1;
|
|
975
1277
|
}
|
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.7";
|
|
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-DqzDElrt.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.7",
|
|
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": {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ui/agent-chat.tsx": {
|
|
3
|
+
"ff662fe56e84cfd0": "0.1.0",
|
|
4
|
+
"89475c1e29eb011b": "0.2.2",
|
|
5
|
+
"af6a34c9f15d4bef": "0.2.5",
|
|
6
|
+
"e0ff5f54b6823dba": "0.2.6",
|
|
7
|
+
"a3542945427a3dfa": "0.2.7"
|
|
8
|
+
},
|
|
9
|
+
"ui/agent-chat.module.css": {
|
|
10
|
+
"8ecfa2904dee75e9": "0.1.0"
|
|
11
|
+
},
|
|
12
|
+
"prompts/presets/general.ts": {
|
|
13
|
+
"10068289d8dfbf09": "0.1.0",
|
|
14
|
+
"fc75d0ecdeb38b7f": "0.2.2"
|
|
15
|
+
},
|
|
16
|
+
"prompts/presets/customer-support.ts": {
|
|
17
|
+
"2fdd5a26b25c23ae": "0.1.0",
|
|
18
|
+
"95bd1451466fb32b": "0.2.2"
|
|
19
|
+
},
|
|
20
|
+
"prompts/presets/internal-ops.ts": {
|
|
21
|
+
"2b150cc147d78e44": "0.1.0",
|
|
22
|
+
"c4b308fa69493fb8": "0.2.2",
|
|
23
|
+
"d5e08199cf6b45c2": "0.2.3"
|
|
24
|
+
},
|
|
25
|
+
"route": {
|
|
26
|
+
"17498e892a470ee4": "0.1.0",
|
|
27
|
+
"c44baa5927649535": "0.2.4"
|
|
28
|
+
},
|
|
29
|
+
"ui/agent-chat.css": {
|
|
30
|
+
"72f30cfb90fe5255": "0.2.2",
|
|
31
|
+
"65d193ef3df15ecc": "0.2.5",
|
|
32
|
+
"a33f338eb017ad66": "0.2.6",
|
|
33
|
+
"155c72a570d1ee5e": "0.2.7"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -142,6 +142,12 @@
|
|
|
142
142
|
color: var(--dopus-muted);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
.dopus-notice {
|
|
146
|
+
align-self: center;
|
|
147
|
+
color: var(--dopus-muted);
|
|
148
|
+
font-size: 0.8125rem;
|
|
149
|
+
}
|
|
150
|
+
|
|
145
151
|
.dopus-error {
|
|
146
152
|
color: var(--dopus-danger);
|
|
147
153
|
}
|
|
@@ -251,6 +257,7 @@
|
|
|
251
257
|
}
|
|
252
258
|
|
|
253
259
|
.dopus-launcher {
|
|
260
|
+
position: relative;
|
|
254
261
|
width: 52px;
|
|
255
262
|
height: 52px;
|
|
256
263
|
border-radius: 50%;
|
|
@@ -262,6 +269,20 @@
|
|
|
262
269
|
box-shadow: 0 6px 20px rgb(0 0 0 / 0.2);
|
|
263
270
|
}
|
|
264
271
|
|
|
272
|
+
/* A reply arrived while the panel was closed (e.g. a background job's follow-up). */
|
|
273
|
+
.dopus-launcher[data-unread]::after {
|
|
274
|
+
content: "";
|
|
275
|
+
position: absolute;
|
|
276
|
+
top: 2px;
|
|
277
|
+
right: 2px;
|
|
278
|
+
width: 12px;
|
|
279
|
+
height: 12px;
|
|
280
|
+
border-radius: 50%;
|
|
281
|
+
/* The launcher sits outside .dopus-chat, where the --dopus-* variables live, so its colors are fixed too. */
|
|
282
|
+
background: #dc2626;
|
|
283
|
+
border: 2px solid #ffffff;
|
|
284
|
+
}
|
|
285
|
+
|
|
265
286
|
/* Assistant replies (Markdown). Compact spacing for a chat bubble. */
|
|
266
287
|
.dopus-markdown {
|
|
267
288
|
white-space: normal;
|
|
@@ -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,7 +46,10 @@ 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',
|
|
52
|
+
unread: 'New reply',
|
|
48
53
|
close: 'Close assistant',
|
|
49
54
|
}
|
|
50
55
|
|
|
@@ -57,6 +62,37 @@ export interface AgentChatProps {
|
|
|
57
62
|
/** Example prompts shown before the first message. */
|
|
58
63
|
suggestions?: string[]
|
|
59
64
|
className?: string
|
|
65
|
+
/**
|
|
66
|
+
* Where the app announces finished background jobs (Supabase Realtime, a WebSocket, SSE, polling...).
|
|
67
|
+
* When a job started in this chat finishes, the agent is told and continues by itself. Create the
|
|
68
|
+
* channel once (module scope or `useMemo`) in a client component; it holds functions, so it cannot be
|
|
69
|
+
* passed from a server component.
|
|
70
|
+
*/
|
|
71
|
+
channel?: AgentChannel
|
|
72
|
+
/** Follow-up turns the agent may take in a row on finished jobs before waiting for the user. Default 3. */
|
|
73
|
+
maxJobFollowUps?: number
|
|
74
|
+
/** Called when the agent finishes a reply. `AgentWidget` uses it to mark the closed launcher as unread. */
|
|
75
|
+
onReply?: () => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AgentChannel {
|
|
79
|
+
/** Start listening and call `finished(jobId)` for each job that finishes. Return a function that stops. */
|
|
80
|
+
subscribe: (finished: (jobId: string) => void) => () => void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Must match the dopus server: a job tool's output carries `dopusJob: <id>`, and a finished job is reported
|
|
84
|
+
// back as a `data-dopus-job` part. The part carries only the ID; the server writes the text the model sees.
|
|
85
|
+
const JOB_KEY = 'dopusJob'
|
|
86
|
+
const JOB_PART = 'data-dopus-job'
|
|
87
|
+
|
|
88
|
+
function startedJob(part: UIMessage['parts'][number]): string | undefined {
|
|
89
|
+
if (!isToolUIPart(part) || part.state !== 'output-available') return undefined
|
|
90
|
+
const id = (part.output as Record<string, unknown> | null)?.[JOB_KEY]
|
|
91
|
+
return typeof id === 'string' ? id : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isJobNotice(message: UIMessage) {
|
|
95
|
+
return message.role === 'user' && message.parts.every((part) => part.type === JOB_PART)
|
|
60
96
|
}
|
|
61
97
|
|
|
62
98
|
type ToolPart = Extract<UIMessage['parts'][number], { toolCallId: string }>
|
|
@@ -78,12 +114,21 @@ function ToolCall({
|
|
|
78
114
|
part,
|
|
79
115
|
labels,
|
|
80
116
|
onRespond,
|
|
117
|
+
waiting,
|
|
81
118
|
}: {
|
|
82
119
|
part: ToolPart
|
|
83
120
|
labels: AgentChatLabels
|
|
84
121
|
onRespond: (approvalId: string, approved: boolean) => void
|
|
122
|
+
waiting: boolean
|
|
85
123
|
}) {
|
|
86
124
|
const name = humanize(getToolName(part))
|
|
125
|
+
if (waiting) {
|
|
126
|
+
return (
|
|
127
|
+
<div className="dopus-tool" data-state="running">
|
|
128
|
+
<span className="dopus-spinner" aria-hidden /> {labels.jobRunning}: {name}
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
87
132
|
|
|
88
133
|
switch (part.state) {
|
|
89
134
|
case 'input-streaming':
|
|
@@ -187,11 +232,16 @@ function Message({
|
|
|
187
232
|
message,
|
|
188
233
|
labels,
|
|
189
234
|
onRespond,
|
|
235
|
+
waitingJobs,
|
|
190
236
|
}: {
|
|
191
237
|
message: UIMessage
|
|
192
238
|
labels: AgentChatLabels
|
|
193
239
|
onRespond: (approvalId: string, approved: boolean) => void
|
|
240
|
+
waitingJobs: Set<string>
|
|
194
241
|
}) {
|
|
242
|
+
if (isJobNotice(message)) {
|
|
243
|
+
return <div className="dopus-notice">✓ {labels.jobDone}</div>
|
|
244
|
+
}
|
|
195
245
|
return (
|
|
196
246
|
<div className="dopus-message" data-role={message.role}>
|
|
197
247
|
{message.parts.map((part, index) => {
|
|
@@ -207,7 +257,16 @@ function Message({
|
|
|
207
257
|
)
|
|
208
258
|
}
|
|
209
259
|
if (isToolUIPart(part)) {
|
|
210
|
-
|
|
260
|
+
const job = startedJob(part)
|
|
261
|
+
return (
|
|
262
|
+
<ToolCall
|
|
263
|
+
key={key}
|
|
264
|
+
part={part}
|
|
265
|
+
labels={labels}
|
|
266
|
+
onRespond={onRespond}
|
|
267
|
+
waiting={job !== undefined && waitingJobs.has(job)}
|
|
268
|
+
/>
|
|
269
|
+
)
|
|
211
270
|
}
|
|
212
271
|
return null
|
|
213
272
|
})}
|
|
@@ -220,9 +279,17 @@ export function AgentChat({
|
|
|
220
279
|
labels: labelOverrides,
|
|
221
280
|
suggestions = [],
|
|
222
281
|
className,
|
|
282
|
+
channel,
|
|
283
|
+
maxJobFollowUps = 3,
|
|
284
|
+
onReply,
|
|
223
285
|
}: AgentChatProps) {
|
|
224
286
|
const labels = { ...DEFAULT_LABELS, ...labelOverrides }
|
|
225
287
|
const router = useRouter()
|
|
288
|
+
// useChat keeps the callbacks it was created with; read the latest onReply through a ref.
|
|
289
|
+
const onReplyRef = useRef(onReply)
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
onReplyRef.current = onReply
|
|
292
|
+
})
|
|
226
293
|
const [transport] = useState(
|
|
227
294
|
() =>
|
|
228
295
|
new DefaultChatTransport({
|
|
@@ -250,12 +317,62 @@ export function AgentChat({
|
|
|
250
317
|
if (message.parts.some((part) => isToolUIPart(part) && part.state === 'output-available')) {
|
|
251
318
|
router.refresh()
|
|
252
319
|
}
|
|
320
|
+
onReplyRef.current?.()
|
|
253
321
|
},
|
|
254
322
|
})
|
|
255
323
|
const [input, setInput] = useState('')
|
|
256
324
|
const listRef = useRef<HTMLDivElement>(null)
|
|
257
325
|
const busy = status === 'submitted' || status === 'streaming'
|
|
258
326
|
|
|
327
|
+
// Jobs the channel reported finished. Kept even before this chat shows the job (a job can finish before
|
|
328
|
+
// its tool output has streamed in); only IDs this chat started are acted on.
|
|
329
|
+
const [finishedJobs, setFinishedJobs] = useState<Set<string>>(() => new Set())
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (!channel) return
|
|
332
|
+
return channel.subscribe((id) => setFinishedJobs((prev) => new Set(prev).add(id)))
|
|
333
|
+
}, [channel])
|
|
334
|
+
|
|
335
|
+
// Derived from the messages, so a new chat (or another tab) never reacts to jobs it did not start.
|
|
336
|
+
const { waitingJobs, readyJobs } = useMemo(() => {
|
|
337
|
+
const started = new Set<string>()
|
|
338
|
+
const reported = new Set<string>()
|
|
339
|
+
for (const message of messages) {
|
|
340
|
+
for (const part of message.parts) {
|
|
341
|
+
const id = startedJob(part)
|
|
342
|
+
if (id) started.add(id)
|
|
343
|
+
if (part.type === JOB_PART) reported.add((part.data as { id: string }).id)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const waiting = new Set([...started].filter((id) => !reported.has(id)))
|
|
347
|
+
return { waitingJobs: waiting, readyJobs: [...waiting].filter((id) => finishedJobs.has(id)) }
|
|
348
|
+
}, [messages, finishedJobs])
|
|
349
|
+
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
if (readyJobs.length === 0 || status !== 'ready') return
|
|
352
|
+
const last = messages.at(-1)
|
|
353
|
+
// Wait while an approval card is open: the user answers that first.
|
|
354
|
+
if (last?.parts.some((part) => isToolUIPart(part) && part.state === 'approval-requested'))
|
|
355
|
+
return
|
|
356
|
+
const notice = {
|
|
357
|
+
role: 'user' as const,
|
|
358
|
+
parts: readyJobs.map(
|
|
359
|
+
(id) => ({ type: JOB_PART, data: { id } }) as UIMessage['parts'][number],
|
|
360
|
+
),
|
|
361
|
+
}
|
|
362
|
+
// Follow-ups since the user last wrote; past the cap, the notice waits in the history for the user's next message.
|
|
363
|
+
let followUps = 0
|
|
364
|
+
for (const message of [...messages].reverse()) {
|
|
365
|
+
if (message.role !== 'user') continue
|
|
366
|
+
if (!isJobNotice(message)) break
|
|
367
|
+
followUps++
|
|
368
|
+
}
|
|
369
|
+
if (followUps >= maxJobFollowUps) {
|
|
370
|
+
setMessages([...messages, { id: `job-${readyJobs.join('-')}`, ...notice }])
|
|
371
|
+
} else {
|
|
372
|
+
sendMessage(notice)
|
|
373
|
+
}
|
|
374
|
+
}, [readyJobs, status, messages, maxJobFollowUps, sendMessage, setMessages])
|
|
375
|
+
|
|
259
376
|
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll whenever messages change
|
|
260
377
|
useEffect(() => {
|
|
261
378
|
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' })
|
|
@@ -322,7 +439,13 @@ export function AgentChat({
|
|
|
322
439
|
</div>
|
|
323
440
|
)}
|
|
324
441
|
{messages.map((message) => (
|
|
325
|
-
<Message
|
|
442
|
+
<Message
|
|
443
|
+
key={message.id}
|
|
444
|
+
message={message}
|
|
445
|
+
labels={labels}
|
|
446
|
+
onRespond={respond}
|
|
447
|
+
waitingJobs={waitingJobs}
|
|
448
|
+
/>
|
|
326
449
|
))}
|
|
327
450
|
{status === 'submitted' && <div className="dopus-thinking">{labels.thinking}</div>}
|
|
328
451
|
{error && (
|
|
@@ -365,19 +488,34 @@ export function AgentChat({
|
|
|
365
488
|
/** Floating button that opens the chat in a panel. Put it once in your root layout. */
|
|
366
489
|
export function AgentWidget(props: AgentChatProps) {
|
|
367
490
|
const [open, setOpen] = useState(false)
|
|
491
|
+
// A reply finished while the panel was closed, e.g. the agent continuing after a background job.
|
|
492
|
+
const [unread, setUnread] = useState(false)
|
|
368
493
|
const labels = { ...DEFAULT_LABELS, ...props.labels }
|
|
369
494
|
return (
|
|
370
495
|
<div className="dopus-widget">
|
|
371
496
|
{/* Hidden rather than unmounted, so closing the panel keeps the conversation. */}
|
|
372
497
|
<div hidden={!open}>
|
|
373
|
-
<AgentChat
|
|
498
|
+
<AgentChat
|
|
499
|
+
{...props}
|
|
500
|
+
className="dopus-panel"
|
|
501
|
+
onReply={() => {
|
|
502
|
+
if (!open) setUnread(true)
|
|
503
|
+
props.onReply?.()
|
|
504
|
+
}}
|
|
505
|
+
/>
|
|
374
506
|
</div>
|
|
375
507
|
<button
|
|
376
508
|
type="button"
|
|
377
509
|
className="dopus-launcher"
|
|
378
510
|
aria-expanded={open}
|
|
379
|
-
aria-label={
|
|
380
|
-
|
|
511
|
+
aria-label={
|
|
512
|
+
open ? labels.close : unread ? `${labels.open} (${labels.unread})` : labels.open
|
|
513
|
+
}
|
|
514
|
+
data-unread={!open && unread ? '' : undefined}
|
|
515
|
+
onClick={() => {
|
|
516
|
+
setOpen((value) => !value)
|
|
517
|
+
setUnread(false)
|
|
518
|
+
}}
|
|
381
519
|
>
|
|
382
520
|
{open ? '×' : '✦'}
|
|
383
521
|
</button>
|