dopus 0.2.4 → 0.2.5

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
@@ -134,14 +134,14 @@ Any other error goes to `onError` and is replaced with a generic message, so dat
134
134
 
135
135
  **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
136
 
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. |
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
+ | Chat UI rendering | Markdown without raw HTML, images never loaded, only http(s)/mailto/relative links: 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. |
145
145
 
146
146
  Your part, so an injected instruction cannot cause damage:
147
147
 
@@ -175,7 +175,7 @@ Anthropic gets an explicit cache breakpoint after layer 3. OpenAI and Gemini cac
175
175
 
176
176
  Both accept `api`, `suggestions`, `labels` (every UI string, for translation) and `className`.
177
177
 
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.
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, 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
179
 
180
180
  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
181
 
@@ -190,6 +190,9 @@ defineAgent({
190
190
  identify: async (request) => ({ id: 'user-1' }), // optional
191
191
  maxSteps: 10, // model ↔ tool round trips per request
192
192
  limits: { maxBodyBytes: 1_000_000, maxMessageChars: 8_000 }, // over → 413
193
+ beforeRequest,
194
+ onToolCall,
195
+ onFinish, // see "Audit log, usage and rate limits"
193
196
  approvalSecret, // default: process.env.DOPUS_APPROVAL_SECRET
194
197
  callOptions: {
195
198
  temperature,
@@ -204,6 +207,44 @@ defineAgent({
204
207
  })
205
208
  ```
206
209
 
210
+ ## Audit log, usage and rate limits
211
+
212
+ Three optional hooks in `defineAgent`. `user` in each comes from `identify`, so define it if you need to know who asked.
213
+
214
+ ```ts
215
+ defineAgent({
216
+ // ...
217
+ identify: async () => {
218
+ const session = await getSession()
219
+ return session ? { id: session.userId } : undefined
220
+ },
221
+
222
+ // Before the model is called. Return a Response to answer with it instead.
223
+ beforeRequest: async ({ user, request, messages }) => {
224
+ const { success } = await ratelimit.limit(user?.id ?? ip(request))
225
+ if (!success) return Response.json({ error: 'Too many requests' }, { status: 429 })
226
+ },
227
+
228
+ // Every tool call: outcome 'success' | 'error' | 'denied' (the user declined the approval).
229
+ onToolCall: async ({ user, tool, input, outcome, output, error, durationMs }) => {
230
+ await db.auditLog.insert({ userId: user?.id, tool, input, outcome, error, durationMs })
231
+ },
232
+
233
+ // Once per request, when the reply is done: tokens summed over all steps.
234
+ onFinish: async ({ user, model, usage, steps, finishReason, durationMs }) => {
235
+ await db.usage.insert({ userId: user?.id, model: model.modelId, ...usage })
236
+ },
237
+ })
238
+ ```
239
+
240
+ - `onToolCall` gets the tool's original error message. It stays on the server; the model and the browser get the safe
241
+ message described under [Errors](#errors).
242
+ - A hook that throws goes to `onError` and does not break the chat. Hooks are awaited, so a slow log write delays the
243
+ reply; queue the write if that matters.
244
+ - `usage.cachedInputTokens` shows prompt caching at work. In the demo task board with Claude, about 90% of input tokens
245
+ were cache reads.
246
+ - The chat UI shows a generic error for a 429. Pass your own `labels.error` if you want different wording.
247
+
207
248
  ## Timeouts and long-running tools
208
249
 
209
250
  An agent turn can take a while: several model calls plus your tools. Two kinds of limits apply, handled differently.
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-DmrGrSmM.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.
@@ -94,6 +94,19 @@ interface AgentConfig<TOOLS extends DopusToolSet = DopusToolSet> {
94
94
  * needed when tools run outside the request. Return `undefined` for anonymous users.
95
95
  */
96
96
  identify?: (request: Request) => AgentUser | undefined | Promise<AgentUser | undefined>;
97
+ /**
98
+ * Runs before the model is called, after the body is validated. Return a `Response` (e.g. a 429 from your rate
99
+ * limiter) to answer with it instead; return nothing to continue.
100
+ */
101
+ beforeRequest?: (ctx: {
102
+ request: Request;
103
+ user: AgentUser | undefined;
104
+ messages: UIMessage[];
105
+ }) => Response | undefined | Promise<Response | undefined>;
106
+ /** Called once per tool call: ran (`success` / `error`) or declined by the user (`denied`). For audit logs. */
107
+ onToolCall?: (event: ToolCallEvent) => void | Promise<void>;
108
+ /** Called once per request when the reply is finished: tokens, steps, model. For usage tracking. */
109
+ onFinish?: (event: FinishEvent) => void | Promise<void>;
97
110
  /** Maximum model steps (tool round trips) per request. Default 10. */
98
111
  maxSteps?: number;
99
112
  /**
@@ -120,6 +133,37 @@ interface AgentConfig<TOOLS extends DopusToolSet = DopusToolSet> {
120
133
  */
121
134
  keepAlive?: number | false;
122
135
  }
136
+ interface ToolCallEvent {
137
+ tool: string;
138
+ toolCallId: string;
139
+ input: unknown;
140
+ /** From `identify`; `undefined` if you did not define it. */
141
+ user: AgentUser | undefined;
142
+ outcome: 'success' | 'error' | 'denied';
143
+ /** The tool's return value (`success`). */
144
+ output?: unknown;
145
+ /** The original error message (`error`). Server-side only: the model and the browser see a safe message. */
146
+ error?: string;
147
+ /** Execution time (`success` / `error`). */
148
+ durationMs?: number;
149
+ }
150
+ interface FinishEvent {
151
+ user: AgentUser | undefined;
152
+ model: {
153
+ provider: string;
154
+ modelId: string;
155
+ };
156
+ /** Summed over all steps of this request. */
157
+ usage: {
158
+ inputTokens: number | undefined;
159
+ outputTokens: number | undefined;
160
+ totalTokens: number | undefined;
161
+ cachedInputTokens: number | undefined;
162
+ };
163
+ steps: number;
164
+ finishReason: string;
165
+ durationMs: number;
166
+ }
123
167
  /** Identity function that gives `agent.config.ts` type checking. */
124
168
  export declare function defineAgent<TOOLS extends DopusToolSet>(config: AgentConfig<TOOLS>): AgentConfig<TOOLS>;
125
169
  /**
@@ -166,5 +210,5 @@ interface ProviderInfo {
166
210
  }
167
211
  /** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
168
212
  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 };
213
+ export declare const VERSION = "0.2.5";
214
+ 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,5 @@
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-DmrGrSmM.js";
2
+ import { convertToModelMessages, createUIMessageStreamResponse, getToolName, isStepCount, isToolUIPart, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
3
3
  // src/next-errors.ts
4
4
  /**
5
5
  * An error whose message is safe to show to the model (and, through it, the user).
@@ -167,6 +167,15 @@ function defineAgent(config) {
167
167
  }
168
168
  return config;
169
169
  }
170
+ /** Runs a user hook; a failing hook is reported but never breaks the chat. */
171
+ async function runHook(hook, event, report) {
172
+ if (!hook) return;
173
+ try {
174
+ await hook(event);
175
+ } catch (error) {
176
+ report?.(error);
177
+ }
178
+ }
170
179
  function buildTools(tools, ctx) {
171
180
  const aiTools = {};
172
181
  const approval = {};
@@ -176,14 +185,34 @@ function buildTools(tools, ctx) {
176
185
  description: toolDescription(options),
177
186
  inputSchema: options.input,
178
187
  execute: async (input, { toolCallId, abortSignal }) => {
188
+ const started = Date.now();
189
+ const base = {
190
+ tool: name,
191
+ toolCallId,
192
+ input,
193
+ user: ctx.user
194
+ };
179
195
  try {
180
196
  const result = await fn(input, {
181
197
  user: ctx.user,
182
198
  toolCallId,
183
199
  abortSignal
184
200
  });
185
- return options.output ? options.output(result) : result;
201
+ const output = options.output ? options.output(result) : result;
202
+ await runHook(ctx.onToolCall, {
203
+ ...base,
204
+ outcome: "success",
205
+ output,
206
+ durationMs: Date.now() - started
207
+ }, ctx.report);
208
+ return output;
186
209
  } catch (error) {
210
+ await runHook(ctx.onToolCall, {
211
+ ...base,
212
+ outcome: "error",
213
+ error: error instanceof Error ? error.message : String(error),
214
+ durationMs: Date.now() - started
215
+ }, ctx.report);
187
216
  const translated = translateNextError(error);
188
217
  if (translated instanceof ToolError) throw translated;
189
218
  ctx.report?.(error);
@@ -232,6 +261,7 @@ function createAgentHandler(config, options = {}) {
232
261
  const maxSteps = config.maxSteps ?? 10;
233
262
  const report = config.onError ?? ((error) => console.error("[dopus]", error));
234
263
  return async function handleAgentRequest(request) {
264
+ const started = Date.now();
235
265
  const deadline = options.maxDuration ? Date.now() + options.maxDuration * 1e3 * TIME_BUDGET_SHARE : void 0;
236
266
  const maxBodyBytes = config.limits?.maxBodyBytes ?? 1e6;
237
267
  const maxMessageChars = config.limits?.maxMessageChars ?? 8e3;
@@ -247,12 +277,29 @@ function createAgentHandler(config, options = {}) {
247
277
  const user = await config.identify?.(request);
248
278
  const { tools, toolApproval } = buildTools(config.tools, {
249
279
  user,
250
- report
280
+ report,
281
+ onToolCall: config.onToolCall
251
282
  });
252
283
  const validated = await safeValidateUIMessages({ messages: body?.messages });
253
284
  if (!validated.success) return errorResponse(400, "Invalid messages");
254
285
  const messages = validated.data;
255
286
  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");
287
+ const early = await config.beforeRequest?.({
288
+ request,
289
+ user,
290
+ messages
291
+ });
292
+ if (early instanceof Response) return early;
293
+ for (const part of messages.at(-1)?.role === "assistant" ? messages.at(-1)?.parts ?? [] : []) {
294
+ if (!isToolUIPart(part) || part.state !== "approval-responded" || part.approval.approved) continue;
295
+ await runHook(config.onToolCall, {
296
+ tool: getToolName(part),
297
+ toolCallId: part.toolCallId,
298
+ input: part.input,
299
+ user,
300
+ outcome: "denied"
301
+ }, report);
302
+ }
256
303
  const client = readClientContext(body, request);
257
304
  const runtime = {
258
305
  ...client,
@@ -285,7 +332,26 @@ function createAgentHandler(config, options = {}) {
285
332
  },
286
333
  abortSignal: request.signal,
287
334
  ...approvalSecret ? { experimental_toolApprovalSecret: approvalSecret } : {},
288
- onError: ({ error }) => report(error)
335
+ onError: ({ error }) => report(error),
336
+ onEnd: async (event) => {
337
+ await config.callOptions?.onEnd?.(event);
338
+ await runHook(config.onFinish, {
339
+ user,
340
+ model: {
341
+ provider: event.model.provider,
342
+ modelId: event.model.modelId
343
+ },
344
+ usage: {
345
+ inputTokens: event.totalUsage.inputTokens,
346
+ outputTokens: event.totalUsage.outputTokens,
347
+ totalTokens: event.totalUsage.totalTokens,
348
+ cachedInputTokens: event.totalUsage.inputTokenDetails?.cacheReadTokens
349
+ },
350
+ steps: event.stepNumber + 1,
351
+ finishReason: event.finishReason,
352
+ durationMs: Date.now() - started
353
+ }, report);
354
+ }
289
355
  });
290
356
  const response = createUIMessageStreamResponse({ stream: toUIMessageStream({
291
357
  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.5";
31
31
  export { PROVIDERS as n, VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dopus",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Drop an AI agent into your existing Next.js app — your server functions become its tools.",
5
5
  "license": "MIT",
6
6
  "author": "akodm",
@@ -70,6 +70,8 @@
70
70
  "ai": "7.0.166",
71
71
  "next": "16.4.6",
72
72
  "react": "19.3.1",
73
+ "react-markdown": "10.1.0",
74
+ "remark-gfm": "4.0.1",
73
75
  "tsdown": "0.23.7",
74
76
  "typescript": "6.0.3",
75
77
  "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);
@@ -257,3 +261,91 @@
257
261
  cursor: pointer;
258
262
  box-shadow: 0 6px 20px rgb(0 0 0 / 0.2);
259
263
  }
264
+
265
+ /* Assistant replies (Markdown). Compact spacing for a chat bubble. */
266
+ .dopus-markdown {
267
+ white-space: normal;
268
+ }
269
+ .dopus-markdown > :first-child {
270
+ margin-top: 0;
271
+ }
272
+ .dopus-markdown > :last-child {
273
+ margin-bottom: 0;
274
+ }
275
+ .dopus-markdown p,
276
+ .dopus-markdown ul,
277
+ .dopus-markdown ol,
278
+ .dopus-markdown pre,
279
+ .dopus-table {
280
+ margin: 0 0 8px;
281
+ }
282
+ .dopus-table {
283
+ overflow-x: auto;
284
+ }
285
+ .dopus-markdown ul,
286
+ .dopus-markdown ol {
287
+ padding-left: 20px;
288
+ }
289
+ .dopus-markdown ul {
290
+ list-style: disc;
291
+ }
292
+ .dopus-markdown ol {
293
+ list-style: decimal;
294
+ }
295
+ .dopus-markdown h1,
296
+ .dopus-markdown h2,
297
+ .dopus-markdown h3,
298
+ .dopus-markdown h4 {
299
+ margin: 12px 0 6px;
300
+ font-size: 15px;
301
+ font-weight: 600;
302
+ }
303
+ .dopus-markdown a {
304
+ color: inherit;
305
+ text-decoration: underline;
306
+ }
307
+ .dopus-markdown strong {
308
+ font-weight: 600;
309
+ }
310
+ .dopus-markdown code {
311
+ padding: 1px 4px;
312
+ border-radius: 4px;
313
+ background: var(--dopus-surface);
314
+ font:
315
+ 12px / 1.4 ui-monospace,
316
+ SFMono-Regular,
317
+ Menlo,
318
+ monospace;
319
+ }
320
+ .dopus-markdown pre {
321
+ padding: 8px 10px;
322
+ border-radius: 8px;
323
+ background: var(--dopus-surface);
324
+ overflow-x: auto;
325
+ }
326
+ .dopus-markdown pre code {
327
+ padding: 0;
328
+ background: none;
329
+ }
330
+ .dopus-markdown table {
331
+ border-collapse: collapse;
332
+ font-size: 13px;
333
+ }
334
+ .dopus-markdown th,
335
+ .dopus-markdown td {
336
+ padding: 4px 8px;
337
+ border: 1px solid var(--dopus-border);
338
+ text-align: left;
339
+ vertical-align: top;
340
+ }
341
+ .dopus-markdown th {
342
+ background: var(--dopus-surface);
343
+ font-weight: 600;
344
+ }
345
+
346
+ /* After .dopus-link, which resets font. */
347
+ .dopus-new-chat {
348
+ font-weight: 400;
349
+ font-size: 13px;
350
+ color: var(--dopus-muted);
351
+ }
@@ -14,7 +14,16 @@ import {
14
14
  type UIMessage,
15
15
  } from 'ai'
16
16
  import { useRouter } from 'next/navigation'
17
- import { type FormEvent, type KeyboardEvent, useEffect, useRef, useState } from 'react'
17
+ import {
18
+ type ComponentProps,
19
+ type FormEvent,
20
+ type KeyboardEvent,
21
+ useEffect,
22
+ useRef,
23
+ useState,
24
+ } from 'react'
25
+ import Markdown, { defaultUrlTransform } from 'react-markdown'
26
+ import remarkGfm from 'remark-gfm'
18
27
  import './agent-chat.css'
19
28
 
20
29
  const DEFAULT_LABELS = {
@@ -23,6 +32,8 @@ const DEFAULT_LABELS = {
23
32
  send: 'Send',
24
33
  stop: 'Stop',
25
34
  retry: 'Retry',
35
+ newChat: 'New chat',
36
+ tooLong: 'Too long to send. Start a new chat or shorten your message.',
26
37
  error: 'Something went wrong.',
27
38
  thinking: 'Thinking…',
28
39
  running: 'Running',
@@ -137,6 +148,41 @@ function ToolCall({
137
148
  }
138
149
  }
139
150
 
151
+ /**
152
+ * Assistant replies are Markdown (tables, lists, bold). Rendered with react-markdown, which never renders raw HTML.
153
+ * Images are shown as their alt text and never loaded, and links keep only http(s), mailto and relative URLs:
154
+ * a reply influenced by prompt injection cannot load a URL that leaks data, or run script.
155
+ */
156
+ const MARKDOWN_COMPONENTS: ComponentProps<typeof Markdown>['components'] = {
157
+ a: ({ href, children }) => (
158
+ <a href={href} target="_blank" rel="noopener noreferrer nofollow">
159
+ {children}
160
+ </a>
161
+ ),
162
+ img: ({ alt }) => (alt ? <span>[{alt}]</span> : null),
163
+ table: ({ children }) => (
164
+ <div className="dopus-table">
165
+ <table>{children}</table>
166
+ </div>
167
+ ),
168
+ }
169
+
170
+ function AssistantText({ text }: { text: string }) {
171
+ return (
172
+ <div className="dopus-text dopus-markdown">
173
+ <Markdown
174
+ remarkPlugins={[remarkGfm]}
175
+ components={MARKDOWN_COMPONENTS}
176
+ urlTransform={(url) =>
177
+ /^(https?:|mailto:|\/|#)/i.test(url) ? defaultUrlTransform(url) : ''
178
+ }
179
+ >
180
+ {text}
181
+ </Markdown>
182
+ </div>
183
+ )
184
+ }
185
+
140
186
  function Message({
141
187
  message,
142
188
  labels,
@@ -151,11 +197,14 @@ function Message({
151
197
  {message.parts.map((part, index) => {
152
198
  const key = `${message.id}-${index}`
153
199
  if (part.type === 'text') {
154
- return part.text ? (
200
+ if (!part.text) return null
201
+ return message.role === 'assistant' ? (
202
+ <AssistantText key={key} text={part.text} />
203
+ ) : (
155
204
  <div key={key} className="dopus-text">
156
205
  {part.text}
157
206
  </div>
158
- ) : null
207
+ )
159
208
  }
160
209
  if (isToolUIPart(part)) {
161
210
  return <ToolCall key={key} part={part} labels={labels} onRespond={onRespond} />
@@ -182,18 +231,27 @@ export function AgentChat({
182
231
  body: () => ({ context: { page: window.location.pathname, locale: navigator.language } }),
183
232
  }),
184
233
  )
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
- })
234
+ const {
235
+ messages,
236
+ setMessages,
237
+ sendMessage,
238
+ status,
239
+ stop,
240
+ error,
241
+ clearError,
242
+ regenerate,
243
+ addToolApprovalResponse,
244
+ } = useChat({
245
+ transport,
246
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
247
+ // A tool ran, so data the page shows may have changed (server actions call revalidatePath):
248
+ // re-render the current route's server components.
249
+ onFinish: ({ message }) => {
250
+ if (message.parts.some((part) => isToolUIPart(part) && part.state === 'output-available')) {
251
+ router.refresh()
252
+ }
253
+ },
254
+ })
197
255
  const [input, setInput] = useState('')
198
256
  const listRef = useRef<HTMLDivElement>(null)
199
257
  const busy = status === 'submitted' || status === 'streaming'
@@ -225,12 +283,29 @@ export function AgentChat({
225
283
 
226
284
  const respond = (id: string, approved: boolean) => addToolApprovalResponse({ id, approved })
227
285
 
286
+ function newChat() {
287
+ if (busy) stop()
288
+ clearError()
289
+ setMessages([])
290
+ setInput('')
291
+ }
292
+
293
+ // The route answers 413 when the history is over its size limit; retrying cannot help then.
294
+ const tooLong = (error as { statusCode?: number } | undefined)?.statusCode === 413
295
+
228
296
  return (
229
297
  <section
230
298
  className={['dopus-chat', className].filter(Boolean).join(' ')}
231
299
  aria-label={labels.title}
232
300
  >
233
- <header className="dopus-header">{labels.title}</header>
301
+ <header className="dopus-header">
302
+ <span>{labels.title}</span>
303
+ {messages.length > 0 && (
304
+ <button type="button" className="dopus-link dopus-new-chat" onClick={newChat}>
305
+ {labels.newChat}
306
+ </button>
307
+ )}
308
+ </header>
234
309
  <div ref={listRef} className="dopus-messages" aria-live="polite">
235
310
  {messages.length === 0 && suggestions.length > 0 && (
236
311
  <div className="dopus-suggestions">
@@ -252,9 +327,13 @@ export function AgentChat({
252
327
  {status === 'submitted' && <div className="dopus-thinking">{labels.thinking}</div>}
253
328
  {error && (
254
329
  <div className="dopus-error" role="alert">
255
- {labels.error}{' '}
256
- <button type="button" className="dopus-link" onClick={() => regenerate()}>
257
- {labels.retry}
330
+ {tooLong ? labels.tooLong : labels.error}{' '}
331
+ <button
332
+ type="button"
333
+ className="dopus-link"
334
+ onClick={() => (tooLong ? newChat() : regenerate())}
335
+ >
336
+ {tooLong ? labels.newChat : labels.retry}
258
337
  </button>
259
338
  </div>
260
339
  )}