dopus 0.2.4 → 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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # dopus
2
2
 
3
- Drop an AI agent into your existing Next.js app. Your server functions become its tools.
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:
@@ -134,14 +164,14 @@ Any other error goes to `onError` and is replaced with a generic message, so dat
134
164
 
135
165
  **Prompt injection.** Assume the model can be talked into anything: by the user, or by text inside tool results that other people wrote (a task title, a comment, an email body). dopus does not rely on the model resisting it. The limits are structural:
136
166
 
137
- | Layer | What it guarantees |
138
- | ---------------------- | -------------------------------------------------------------------------------------------------- |
139
- | Permission inheritance | A tool can do only what the signed-in user can do through your server functions. |
140
- | `confirm: true` | The user sees the exact input and must approve; signed approvals cannot be forged or edited. |
141
- | Plain-text chat UI | Replies render as text, never HTML or Markdown, so no XSS and no image-URL data leaks. |
142
- | Request limits | `limits.maxBodyBytes` (1 MB) and `limits.maxMessageChars` (8000) answer 413 before the model runs. |
143
- | `maxSteps` | At most 10 model ↔ tool round trips per request. |
144
- | Core prompt | Tells the model that tool results are data, not instructions. Helpful, but not a guarantee. |
167
+ | Layer | What it guarantees |
168
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------- |
169
+ | Permission inheritance | A tool can do only what the signed-in user can do through your server functions. |
170
+ | `confirm: true` | The user sees the exact input and must approve; signed approvals cannot be forged or edited. |
171
+ | Chat UI rendering | Markdown without raw HTML, images never loaded, only http(s)/mailto/relative links: no XSS and no image-URL data leaks. |
172
+ | Request limits | `limits.maxBodyBytes` (1 MB) and `limits.maxMessageChars` (8000) answer 413 before the model runs. |
173
+ | `maxSteps` | At most 10 model ↔ tool round trips per request. |
174
+ | Core prompt | Tells the model that tool results are data, not instructions. Helpful, but not a guarantee. |
145
175
 
146
176
  Your part, so an injected instruction cannot cause damage:
147
177
 
@@ -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. Infra adapters that run tools elsewhere will build on this.
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,9 +203,10 @@ 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 `className`.
206
+ Both accept `api`, `suggestions`, `labels` (every UI string, for translation), `className`, and `channel` /
207
+ `maxJobFollowUps` for [background jobs](#background-jobs).
177
208
 
178
- 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. It also has stop and retry buttons, and it does not send on Enter while an IME (Korean, Japanese, Chinese) is still composing.
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
 
180
211
  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
212
 
@@ -190,6 +221,9 @@ defineAgent({
190
221
  identify: async (request) => ({ id: 'user-1' }), // optional
191
222
  maxSteps: 10, // model ↔ tool round trips per request
192
223
  limits: { maxBodyBytes: 1_000_000, maxMessageChars: 8_000 }, // over → 413
224
+ beforeRequest,
225
+ onToolCall,
226
+ onFinish, // see "Audit log, usage and rate limits"
193
227
  approvalSecret, // default: process.env.DOPUS_APPROVAL_SECRET
194
228
  callOptions: {
195
229
  temperature,
@@ -204,6 +238,44 @@ defineAgent({
204
238
  })
205
239
  ```
206
240
 
241
+ ## Audit log, usage and rate limits
242
+
243
+ Three optional hooks in `defineAgent`. `user` in each comes from `identify`, so define it if you need to know who asked.
244
+
245
+ ```ts
246
+ defineAgent({
247
+ // ...
248
+ identify: async () => {
249
+ const session = await getSession()
250
+ return session ? { id: session.userId } : undefined
251
+ },
252
+
253
+ // Before the model is called. Return a Response to answer with it instead.
254
+ beforeRequest: async ({ user, request, messages }) => {
255
+ const { success } = await ratelimit.limit(user?.id ?? ip(request))
256
+ if (!success) return Response.json({ error: 'Too many requests' }, { status: 429 })
257
+ },
258
+
259
+ // Every tool call: outcome 'success' | 'error' | 'denied' (the user declined the approval).
260
+ onToolCall: async ({ user, tool, input, outcome, output, error, durationMs }) => {
261
+ await db.auditLog.insert({ userId: user?.id, tool, input, outcome, error, durationMs })
262
+ },
263
+
264
+ // Once per request, when the reply is done: tokens summed over all steps.
265
+ onFinish: async ({ user, model, usage, steps, finishReason, durationMs }) => {
266
+ await db.usage.insert({ userId: user?.id, model: model.modelId, ...usage })
267
+ },
268
+ })
269
+ ```
270
+
271
+ - `onToolCall` gets the tool's original error message. It stays on the server; the model and the browser get the safe
272
+ message described under [Errors](#errors).
273
+ - A hook that throws goes to `onError` and does not break the chat. Hooks are awaited, so a slow log write delays the
274
+ reply; queue the write if that matters.
275
+ - `usage.cachedInputTokens` shows prompt caching at work. In the demo task board with Claude, about 90% of input tokens
276
+ were cache reads.
277
+ - The chat UI shows a generic error for a 429. Pass your own `labels.error` if you want different wording.
278
+
207
279
  ## Timeouts and long-running tools
208
280
 
209
281
  An agent turn can take a while: several model calls plus your tools. Two kinds of limits apply, handled differently.
@@ -247,11 +319,98 @@ The job runs in your queue or background worker, outside the request, so it does
247
319
  permissions in `startExportJob` and pass the user to the job explicitly (see `identify` and `ctx.user`). Have `getExportJob`
248
320
  check that the job belongs to the caller.
249
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
+
250
325
  **If a turn is cut anyway**, tools that already ran stay done. "Retry" asks the model again and may call a tool again.
251
326
  Tools behind `confirm: true` need a new approval; make other writes idempotent.
252
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
+
253
409
  ## Deploying
254
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
+
255
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.
256
415
 
257
416
  **AWS (OpenNext / SST).**
@@ -329,9 +488,10 @@ Swap in a scripted `MockLanguageModelV4` from `ai/test` behind an environment va
329
488
 
330
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.
331
490
 
332
- Planned next:
491
+ Planned next, within the lightweight scope above:
333
492
 
334
- - `dopus prompts diff` to compare your copies with newer templates;
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);
335
495
  - tool input schemas inferred from TypeScript types.
336
496
 
337
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-CNF5aJ7W.js";
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";
@@ -367,7 +367,9 @@ async function runInit(options, io) {
367
367
  ["ai", "ai@^7"],
368
368
  ["zod", "zod@^4"],
369
369
  [info.packageName, `${info.packageName}@^4`],
370
- ["@ai-sdk/react", "@ai-sdk/react@^4"]
370
+ ["@ai-sdk/react", "@ai-sdk/react@^4"],
371
+ ["react-markdown", "react-markdown@^10"],
372
+ ["remark-gfm", "remark-gfm@^4"]
371
373
  ].filter(([name]) => !project.dependencies[name]).map(([, spec]) => spec);
372
374
  if (missing.length > 0) {
373
375
  if (options.install === false) io.warn(`Install these yourself: ${project.packageManager} ${installArgs(project.packageManager, missing).join(" ")}`);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { LanguageModel, SystemModelMessage, streamText } from "ai";
1
+ import { LanguageModel, SystemModelMessage, UIMessage, streamText } from "ai";
2
2
  import { z } from "zod";
3
3
  /**
4
4
  * Layer 1 (Core). Kept short: tool-use and safety rules that apply to every app.
@@ -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> {
@@ -94,6 +100,19 @@ interface AgentConfig<TOOLS extends DopusToolSet = DopusToolSet> {
94
100
  * needed when tools run outside the request. Return `undefined` for anonymous users.
95
101
  */
96
102
  identify?: (request: Request) => AgentUser | undefined | Promise<AgentUser | undefined>;
103
+ /**
104
+ * Runs before the model is called, after the body is validated. Return a `Response` (e.g. a 429 from your rate
105
+ * limiter) to answer with it instead; return nothing to continue.
106
+ */
107
+ beforeRequest?: (ctx: {
108
+ request: Request;
109
+ user: AgentUser | undefined;
110
+ messages: UIMessage[];
111
+ }) => Response | undefined | Promise<Response | undefined>;
112
+ /** Called once per tool call: ran (`success` / `error`) or declined by the user (`denied`). For audit logs. */
113
+ onToolCall?: (event: ToolCallEvent) => void | Promise<void>;
114
+ /** Called once per request when the reply is finished: tokens, steps, model. For usage tracking. */
115
+ onFinish?: (event: FinishEvent) => void | Promise<void>;
97
116
  /** Maximum model steps (tool round trips) per request. Default 10. */
98
117
  maxSteps?: number;
99
118
  /**
@@ -120,6 +139,37 @@ interface AgentConfig<TOOLS extends DopusToolSet = DopusToolSet> {
120
139
  */
121
140
  keepAlive?: number | false;
122
141
  }
142
+ interface ToolCallEvent {
143
+ tool: string;
144
+ toolCallId: string;
145
+ input: unknown;
146
+ /** From `identify`; `undefined` if you did not define it. */
147
+ user: AgentUser | undefined;
148
+ outcome: 'success' | 'error' | 'denied';
149
+ /** The tool's return value (`success`). */
150
+ output?: unknown;
151
+ /** The original error message (`error`). Server-side only: the model and the browser see a safe message. */
152
+ error?: string;
153
+ /** Execution time (`success` / `error`). */
154
+ durationMs?: number;
155
+ }
156
+ interface FinishEvent {
157
+ user: AgentUser | undefined;
158
+ model: {
159
+ provider: string;
160
+ modelId: string;
161
+ };
162
+ /** Summed over all steps of this request. */
163
+ usage: {
164
+ inputTokens: number | undefined;
165
+ outputTokens: number | undefined;
166
+ totalTokens: number | undefined;
167
+ cachedInputTokens: number | undefined;
168
+ };
169
+ steps: number;
170
+ finishReason: string;
171
+ durationMs: number;
172
+ }
123
173
  /** Identity function that gives `agent.config.ts` type checking. */
124
174
  export declare function defineAgent<TOOLS extends DopusToolSet>(config: AgentConfig<TOOLS>): AgentConfig<TOOLS>;
125
175
  /**
@@ -166,5 +216,5 @@ interface ProviderInfo {
166
216
  }
167
217
  /** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
168
218
  export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
169
- export declare const VERSION = "0.2.4";
170
- export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, HandlerOptions, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolContext, ToolOptions };
219
+ export declare const VERSION = "0.2.6";
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-CNF5aJ7W.js";
2
- import { convertToModelMessages, createUIMessageStreamResponse, isStepCount, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
1
+ import { n as PROVIDERS, t as VERSION } from "./version-Dm8IBEUF.js";
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
- return usage ? `${description}\n\nWhen to use: ${usage}` : description;
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");
@@ -167,6 +195,15 @@ function defineAgent(config) {
167
195
  }
168
196
  return config;
169
197
  }
198
+ /** Runs a user hook; a failing hook is reported but never breaks the chat. */
199
+ async function runHook(hook, event, report) {
200
+ if (!hook) return;
201
+ try {
202
+ await hook(event);
203
+ } catch (error) {
204
+ report?.(error);
205
+ }
206
+ }
170
207
  function buildTools(tools, ctx) {
171
208
  const aiTools = {};
172
209
  const approval = {};
@@ -176,14 +213,40 @@ function buildTools(tools, ctx) {
176
213
  description: toolDescription(options),
177
214
  inputSchema: options.input,
178
215
  execute: async (input, { toolCallId, abortSignal }) => {
216
+ const started = Date.now();
217
+ const base = {
218
+ tool: name,
219
+ toolCallId,
220
+ input,
221
+ user: ctx.user
222
+ };
179
223
  try {
180
224
  const result = await fn(input, {
181
225
  user: ctx.user,
182
226
  toolCallId,
183
227
  abortSignal
184
228
  });
185
- return options.output ? options.output(result) : result;
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
+ }
236
+ await runHook(ctx.onToolCall, {
237
+ ...base,
238
+ outcome: "success",
239
+ output,
240
+ durationMs: Date.now() - started
241
+ }, ctx.report);
242
+ return output;
186
243
  } catch (error) {
244
+ await runHook(ctx.onToolCall, {
245
+ ...base,
246
+ outcome: "error",
247
+ error: error instanceof Error ? error.message : String(error),
248
+ durationMs: Date.now() - started
249
+ }, ctx.report);
187
250
  const translated = translateNextError(error);
188
251
  if (translated instanceof ToolError) throw translated;
189
252
  ctx.report?.(error);
@@ -232,6 +295,7 @@ function createAgentHandler(config, options = {}) {
232
295
  const maxSteps = config.maxSteps ?? 10;
233
296
  const report = config.onError ?? ((error) => console.error("[dopus]", error));
234
297
  return async function handleAgentRequest(request) {
298
+ const started = Date.now();
235
299
  const deadline = options.maxDuration ? Date.now() + options.maxDuration * 1e3 * TIME_BUDGET_SHARE : void 0;
236
300
  const maxBodyBytes = config.limits?.maxBodyBytes ?? 1e6;
237
301
  const maxMessageChars = config.limits?.maxMessageChars ?? 8e3;
@@ -247,12 +311,30 @@ function createAgentHandler(config, options = {}) {
247
311
  const user = await config.identify?.(request);
248
312
  const { tools, toolApproval } = buildTools(config.tools, {
249
313
  user,
250
- report
314
+ report,
315
+ onToolCall: config.onToolCall
251
316
  });
252
317
  const validated = await safeValidateUIMessages({ messages: body?.messages });
253
318
  if (!validated.success) return errorResponse(400, "Invalid messages");
254
319
  const messages = validated.data;
255
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");
322
+ const early = await config.beforeRequest?.({
323
+ request,
324
+ user,
325
+ messages
326
+ });
327
+ if (early instanceof Response) return early;
328
+ for (const part of messages.at(-1)?.role === "assistant" ? messages.at(-1)?.parts ?? [] : []) {
329
+ if (!isToolUIPart(part) || part.state !== "approval-responded" || part.approval.approved) continue;
330
+ await runHook(config.onToolCall, {
331
+ tool: getToolName(part),
332
+ toolCallId: part.toolCallId,
333
+ input: part.input,
334
+ user,
335
+ outcome: "denied"
336
+ }, report);
337
+ }
256
338
  const client = readClientContext(body, request);
257
339
  const runtime = {
258
340
  ...client,
@@ -266,7 +348,13 @@ function createAgentHandler(config, options = {}) {
266
348
  ...config.callOptions,
267
349
  model: config.model,
268
350
  instructions: buildInstructions(config.prompt, runtime),
269
- messages: await convertToModelMessages(messages, { tools }),
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
+ }),
270
358
  tools,
271
359
  toolApproval,
272
360
  stopWhen: isStepCount(maxSteps),
@@ -285,7 +373,26 @@ function createAgentHandler(config, options = {}) {
285
373
  },
286
374
  abortSignal: request.signal,
287
375
  ...approvalSecret ? { experimental_toolApprovalSecret: approvalSecret } : {},
288
- onError: ({ error }) => report(error)
376
+ onError: ({ error }) => report(error),
377
+ onEnd: async (event) => {
378
+ await config.callOptions?.onEnd?.(event);
379
+ await runHook(config.onFinish, {
380
+ user,
381
+ model: {
382
+ provider: event.model.provider,
383
+ modelId: event.model.modelId
384
+ },
385
+ usage: {
386
+ inputTokens: event.totalUsage.inputTokens,
387
+ outputTokens: event.totalUsage.outputTokens,
388
+ totalTokens: event.totalUsage.totalTokens,
389
+ cachedInputTokens: event.totalUsage.inputTokenDetails?.cacheReadTokens
390
+ },
391
+ steps: event.stepNumber + 1,
392
+ finishReason: event.finishReason,
393
+ durationMs: Date.now() - started
394
+ }, report);
395
+ }
289
396
  });
290
397
  const response = createUIMessageStreamResponse({ stream: toUIMessageStream({
291
398
  stream: result.stream,
@@ -27,5 +27,5 @@ const PROVIDERS = {
27
27
  }
28
28
  };
29
29
  // src/version.ts
30
- const VERSION = "0.2.4";
30
+ const VERSION = "0.2.6";
31
31
  export { PROVIDERS as n, VERSION as t };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dopus",
3
- "version": "0.2.4",
4
- "description": "Drop an AI agent into your existing Next.js app — your server functions become its tools.",
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": {
@@ -70,6 +73,8 @@
70
73
  "ai": "7.0.166",
71
74
  "next": "16.4.6",
72
75
  "react": "19.3.1",
76
+ "react-markdown": "10.1.0",
77
+ "remark-gfm": "4.0.1",
73
78
  "tsdown": "0.23.7",
74
79
  "typescript": "6.0.3",
75
80
  "vitest": "5.1.0",
@@ -44,6 +44,10 @@
44
44
  }
45
45
 
46
46
  .dopus-header {
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: space-between;
50
+ gap: 8px;
47
51
  padding: 12px 16px;
48
52
  font-weight: 600;
49
53
  border-bottom: 1px solid var(--dopus-border);
@@ -138,6 +142,12 @@
138
142
  color: var(--dopus-muted);
139
143
  }
140
144
 
145
+ .dopus-notice {
146
+ align-self: center;
147
+ color: var(--dopus-muted);
148
+ font-size: 0.8125rem;
149
+ }
150
+
141
151
  .dopus-error {
142
152
  color: var(--dopus-danger);
143
153
  }
@@ -257,3 +267,91 @@
257
267
  cursor: pointer;
258
268
  box-shadow: 0 6px 20px rgb(0 0 0 / 0.2);
259
269
  }
270
+
271
+ /* Assistant replies (Markdown). Compact spacing for a chat bubble. */
272
+ .dopus-markdown {
273
+ white-space: normal;
274
+ }
275
+ .dopus-markdown > :first-child {
276
+ margin-top: 0;
277
+ }
278
+ .dopus-markdown > :last-child {
279
+ margin-bottom: 0;
280
+ }
281
+ .dopus-markdown p,
282
+ .dopus-markdown ul,
283
+ .dopus-markdown ol,
284
+ .dopus-markdown pre,
285
+ .dopus-table {
286
+ margin: 0 0 8px;
287
+ }
288
+ .dopus-table {
289
+ overflow-x: auto;
290
+ }
291
+ .dopus-markdown ul,
292
+ .dopus-markdown ol {
293
+ padding-left: 20px;
294
+ }
295
+ .dopus-markdown ul {
296
+ list-style: disc;
297
+ }
298
+ .dopus-markdown ol {
299
+ list-style: decimal;
300
+ }
301
+ .dopus-markdown h1,
302
+ .dopus-markdown h2,
303
+ .dopus-markdown h3,
304
+ .dopus-markdown h4 {
305
+ margin: 12px 0 6px;
306
+ font-size: 15px;
307
+ font-weight: 600;
308
+ }
309
+ .dopus-markdown a {
310
+ color: inherit;
311
+ text-decoration: underline;
312
+ }
313
+ .dopus-markdown strong {
314
+ font-weight: 600;
315
+ }
316
+ .dopus-markdown code {
317
+ padding: 1px 4px;
318
+ border-radius: 4px;
319
+ background: var(--dopus-surface);
320
+ font:
321
+ 12px / 1.4 ui-monospace,
322
+ SFMono-Regular,
323
+ Menlo,
324
+ monospace;
325
+ }
326
+ .dopus-markdown pre {
327
+ padding: 8px 10px;
328
+ border-radius: 8px;
329
+ background: var(--dopus-surface);
330
+ overflow-x: auto;
331
+ }
332
+ .dopus-markdown pre code {
333
+ padding: 0;
334
+ background: none;
335
+ }
336
+ .dopus-markdown table {
337
+ border-collapse: collapse;
338
+ font-size: 13px;
339
+ }
340
+ .dopus-markdown th,
341
+ .dopus-markdown td {
342
+ padding: 4px 8px;
343
+ border: 1px solid var(--dopus-border);
344
+ text-align: left;
345
+ vertical-align: top;
346
+ }
347
+ .dopus-markdown th {
348
+ background: var(--dopus-surface);
349
+ font-weight: 600;
350
+ }
351
+
352
+ /* After .dopus-link, which resets font. */
353
+ .dopus-new-chat {
354
+ font-weight: 400;
355
+ font-size: 13px;
356
+ color: var(--dopus-muted);
357
+ }
@@ -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 {
@@ -14,7 +15,17 @@ import {
14
15
  type UIMessage,
15
16
  } from 'ai'
16
17
  import { useRouter } from 'next/navigation'
17
- import { type FormEvent, type KeyboardEvent, useEffect, useRef, useState } from 'react'
18
+ import {
19
+ type ComponentProps,
20
+ type FormEvent,
21
+ type KeyboardEvent,
22
+ useEffect,
23
+ useMemo,
24
+ useRef,
25
+ useState,
26
+ } from 'react'
27
+ import Markdown, { defaultUrlTransform } from 'react-markdown'
28
+ import remarkGfm from 'remark-gfm'
18
29
  import './agent-chat.css'
19
30
 
20
31
  const DEFAULT_LABELS = {
@@ -23,6 +34,8 @@ const DEFAULT_LABELS = {
23
34
  send: 'Send',
24
35
  stop: 'Stop',
25
36
  retry: 'Retry',
37
+ newChat: 'New chat',
38
+ tooLong: 'Too long to send. Start a new chat or shorten your message.',
26
39
  error: 'Something went wrong.',
27
40
  thinking: 'Thinking…',
28
41
  running: 'Running',
@@ -33,6 +46,8 @@ const DEFAULT_LABELS = {
33
46
  declined: 'Declined',
34
47
  failed: 'Failed',
35
48
  details: 'Details',
49
+ jobRunning: 'Working in the background',
50
+ jobDone: 'Background task finished',
36
51
  open: 'Open assistant',
37
52
  close: 'Close assistant',
38
53
  }
@@ -46,6 +61,35 @@ export interface AgentChatProps {
46
61
  /** Example prompts shown before the first message. */
47
62
  suggestions?: string[]
48
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)
49
93
  }
50
94
 
51
95
  type ToolPart = Extract<UIMessage['parts'][number], { toolCallId: string }>
@@ -67,12 +111,21 @@ function ToolCall({
67
111
  part,
68
112
  labels,
69
113
  onRespond,
114
+ waiting,
70
115
  }: {
71
116
  part: ToolPart
72
117
  labels: AgentChatLabels
73
118
  onRespond: (approvalId: string, approved: boolean) => void
119
+ waiting: boolean
74
120
  }) {
75
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
+ }
76
129
 
77
130
  switch (part.state) {
78
131
  case 'input-streaming':
@@ -137,28 +190,80 @@ function ToolCall({
137
190
  }
138
191
  }
139
192
 
193
+ /**
194
+ * Assistant replies are Markdown (tables, lists, bold). Rendered with react-markdown, which never renders raw HTML.
195
+ * Images are shown as their alt text and never loaded, and links keep only http(s), mailto and relative URLs:
196
+ * a reply influenced by prompt injection cannot load a URL that leaks data, or run script.
197
+ */
198
+ const MARKDOWN_COMPONENTS: ComponentProps<typeof Markdown>['components'] = {
199
+ a: ({ href, children }) => (
200
+ <a href={href} target="_blank" rel="noopener noreferrer nofollow">
201
+ {children}
202
+ </a>
203
+ ),
204
+ img: ({ alt }) => (alt ? <span>[{alt}]</span> : null),
205
+ table: ({ children }) => (
206
+ <div className="dopus-table">
207
+ <table>{children}</table>
208
+ </div>
209
+ ),
210
+ }
211
+
212
+ function AssistantText({ text }: { text: string }) {
213
+ return (
214
+ <div className="dopus-text dopus-markdown">
215
+ <Markdown
216
+ remarkPlugins={[remarkGfm]}
217
+ components={MARKDOWN_COMPONENTS}
218
+ urlTransform={(url) =>
219
+ /^(https?:|mailto:|\/|#)/i.test(url) ? defaultUrlTransform(url) : ''
220
+ }
221
+ >
222
+ {text}
223
+ </Markdown>
224
+ </div>
225
+ )
226
+ }
227
+
140
228
  function Message({
141
229
  message,
142
230
  labels,
143
231
  onRespond,
232
+ waitingJobs,
144
233
  }: {
145
234
  message: UIMessage
146
235
  labels: AgentChatLabels
147
236
  onRespond: (approvalId: string, approved: boolean) => void
237
+ waitingJobs: Set<string>
148
238
  }) {
239
+ if (isJobNotice(message)) {
240
+ return <div className="dopus-notice">✓ {labels.jobDone}</div>
241
+ }
149
242
  return (
150
243
  <div className="dopus-message" data-role={message.role}>
151
244
  {message.parts.map((part, index) => {
152
245
  const key = `${message.id}-${index}`
153
246
  if (part.type === 'text') {
154
- return part.text ? (
247
+ if (!part.text) return null
248
+ return message.role === 'assistant' ? (
249
+ <AssistantText key={key} text={part.text} />
250
+ ) : (
155
251
  <div key={key} className="dopus-text">
156
252
  {part.text}
157
253
  </div>
158
- ) : null
254
+ )
159
255
  }
160
256
  if (isToolUIPart(part)) {
161
- return <ToolCall key={key} part={part} labels={labels} onRespond={onRespond} />
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
+ )
162
267
  }
163
268
  return null
164
269
  })}
@@ -171,6 +276,8 @@ export function AgentChat({
171
276
  labels: labelOverrides,
172
277
  suggestions = [],
173
278
  className,
279
+ channel,
280
+ maxJobFollowUps = 3,
174
281
  }: AgentChatProps) {
175
282
  const labels = { ...DEFAULT_LABELS, ...labelOverrides }
176
283
  const router = useRouter()
@@ -182,22 +289,80 @@ export function AgentChat({
182
289
  body: () => ({ context: { page: window.location.pathname, locale: navigator.language } }),
183
290
  }),
184
291
  )
185
- const { messages, sendMessage, status, stop, error, regenerate, addToolApprovalResponse } =
186
- useChat({
187
- transport,
188
- sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
189
- // A tool ran, so data the page shows may have changed (server actions call revalidatePath):
190
- // re-render the current route's server components.
191
- onFinish: ({ message }) => {
192
- if (message.parts.some((part) => isToolUIPart(part) && part.state === 'output-available')) {
193
- router.refresh()
194
- }
195
- },
196
- })
292
+ const {
293
+ messages,
294
+ setMessages,
295
+ sendMessage,
296
+ status,
297
+ stop,
298
+ error,
299
+ clearError,
300
+ regenerate,
301
+ addToolApprovalResponse,
302
+ } = useChat({
303
+ transport,
304
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
305
+ // A tool ran, so data the page shows may have changed (server actions call revalidatePath):
306
+ // re-render the current route's server components.
307
+ onFinish: ({ message }) => {
308
+ if (message.parts.some((part) => isToolUIPart(part) && part.state === 'output-available')) {
309
+ router.refresh()
310
+ }
311
+ },
312
+ })
197
313
  const [input, setInput] = useState('')
198
314
  const listRef = useRef<HTMLDivElement>(null)
199
315
  const busy = status === 'submitted' || status === 'streaming'
200
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
+
201
366
  // biome-ignore lint/correctness/useExhaustiveDependencies: scroll whenever messages change
202
367
  useEffect(() => {
203
368
  listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' })
@@ -225,12 +390,29 @@ export function AgentChat({
225
390
 
226
391
  const respond = (id: string, approved: boolean) => addToolApprovalResponse({ id, approved })
227
392
 
393
+ function newChat() {
394
+ if (busy) stop()
395
+ clearError()
396
+ setMessages([])
397
+ setInput('')
398
+ }
399
+
400
+ // The route answers 413 when the history is over its size limit; retrying cannot help then.
401
+ const tooLong = (error as { statusCode?: number } | undefined)?.statusCode === 413
402
+
228
403
  return (
229
404
  <section
230
405
  className={['dopus-chat', className].filter(Boolean).join(' ')}
231
406
  aria-label={labels.title}
232
407
  >
233
- <header className="dopus-header">{labels.title}</header>
408
+ <header className="dopus-header">
409
+ <span>{labels.title}</span>
410
+ {messages.length > 0 && (
411
+ <button type="button" className="dopus-link dopus-new-chat" onClick={newChat}>
412
+ {labels.newChat}
413
+ </button>
414
+ )}
415
+ </header>
234
416
  <div ref={listRef} className="dopus-messages" aria-live="polite">
235
417
  {messages.length === 0 && suggestions.length > 0 && (
236
418
  <div className="dopus-suggestions">
@@ -247,14 +429,24 @@ export function AgentChat({
247
429
  </div>
248
430
  )}
249
431
  {messages.map((message) => (
250
- <Message key={message.id} message={message} labels={labels} onRespond={respond} />
432
+ <Message
433
+ key={message.id}
434
+ message={message}
435
+ labels={labels}
436
+ onRespond={respond}
437
+ waitingJobs={waitingJobs}
438
+ />
251
439
  ))}
252
440
  {status === 'submitted' && <div className="dopus-thinking">{labels.thinking}</div>}
253
441
  {error && (
254
442
  <div className="dopus-error" role="alert">
255
- {labels.error}{' '}
256
- <button type="button" className="dopus-link" onClick={() => regenerate()}>
257
- {labels.retry}
443
+ {tooLong ? labels.tooLong : labels.error}{' '}
444
+ <button
445
+ type="button"
446
+ className="dopus-link"
447
+ onClick={() => (tooLong ? newChat() : regenerate())}
448
+ >
449
+ {tooLong ? labels.newChat : labels.retry}
258
450
  </button>
259
451
  </div>
260
452
  )}