dopus 0.2.3 → 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,9 +207,93 @@ 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
+
248
+ ## Timeouts and long-running tools
249
+
250
+ An agent turn can take a while: several model calls plus your tools. Two kinds of limits apply, handled differently.
251
+
252
+ **Idle timeouts** (CloudFront, ALB, nginx: usually 60 s without a byte). Handled:
253
+
254
+ - The route streams tokens and tool states as they happen; it never waits for the whole answer.
255
+ - While a slow tool runs, an SSE comment (`: keep-alive`) goes out every 15 s (`keepAlive` option). Clients ignore it.
256
+ - Waiting for an approval holds nothing open. The request ends at the approval card, and the answer starts a new one.
257
+
258
+ **The platform's total limit** for one request (Vercel `maxDuration`, Lambda timeout, Cloud Run `--timeout`). It cannot be
259
+ streamed around, so dopus stays inside it:
260
+
261
+ ```ts
262
+ // app/api/agent/route.ts (as generated)
263
+ export const maxDuration = 60
264
+ export const POST = createAgentHandler(agent, { maxDuration })
265
+ ```
266
+
267
+ With `maxDuration` passed, once 75% of it has passed the agent starts no more tool calls. It answers with what it has done
268
+ and says what is left, and the user can reply "continue". Without it, a long turn is cut off mid-stream and the chat shows
269
+ an error. A step already running (one model call or one tool) is not interrupted, so keep single tools well under the
270
+ limit. `maxSteps` (default 10) also bounds a turn.
271
+
272
+ **Tools that take longer than a request.** Exports, bulk updates, report generation and the like should not run inside a
273
+ tool call. Split them into two tools:
274
+
275
+ ```ts
276
+ startExport: defineTool((input) => startExportJob(input), { // enqueue, return { jobId } at once
277
+ description: 'Starts a CSV export and returns a job id.',
278
+ input: z.object({ from: z.string(), to: z.string() }),
279
+ confirm: true,
280
+ }),
281
+ exportStatus: defineTool((input) => getExportJob(input.jobId), { // { status, url? }
282
+ description: 'Returns the status of an export job and its download link when done.',
283
+ input: z.object({ jobId: z.string() }),
284
+ }),
285
+ ```
286
+
287
+ The job runs in your queue or background worker, outside the request, so it does not have the request's cookies. Check
288
+ permissions in `startExportJob` and pass the user to the job explicitly (see `identify` and `ctx.user`). Have `getExportJob`
289
+ check that the job belongs to the caller.
290
+
291
+ **If a turn is cut anyway**, tools that already ran stay done. "Retry" asks the model again and may call a tool again.
292
+ Tools behind `confirm: true` need a new approval; make other writes idempotent.
293
+
207
294
  ## Deploying
208
295
 
209
- **Vercel / any Node server.** Nothing extra. The route streams. Raise `maxDuration` in `app/api/agent/route.ts` if your plan allows it.
296
+ **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.
210
297
 
211
298
  **AWS (OpenNext / SST).**
212
299
 
@@ -221,7 +308,7 @@ SST reads OpenNext's streaming flag and sets the Function URL to `RESPONSE_STREA
221
308
  Things to know:
222
309
 
223
310
  - **Keep-alive.** While a tool runs and nothing else is sent, dopus writes an SSE comment (`: keep-alive`) every 15 s (`keepAlive` option). Clients ignore it. CloudFront, ALB and nginx time out on gaps between packets, not on total time, so a slow tool does not cut the stream.
224
- - **Turn length.** A turn still has to finish within the Lambda timeout. Behind CloudFront that is 60 s by default (up to 180 s with a quota increase). A bare Function URL allows up to 15 min.
311
+ - **Turn length.** A turn still has to finish within the Lambda timeout. Behind CloudFront that is 60 s by default (up to 180 s with a quota increase). A bare Function URL allows up to 15 min. Keep the route's `maxDuration` equal to the Lambda timeout so the time budget matches.
225
312
  - **No API Gateway.** Don't put API Gateway HTTP APIs in front of `/api/agent`. They cap requests at 30 s and buffer the response.
226
313
  - **Long-running work.** Work that outlives a request, such as queues and background jobs, loses the request's cookies. Use `identify` and pass `ctx.user` on explicitly.
227
314
 
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-CwDWliFC.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";
@@ -227,10 +227,11 @@ function routeTemplate(configImport) {
227
227
  return `import { createAgentHandler } from 'dopus'
228
228
  import agent from '${configImport}'
229
229
 
230
- // Agent replies stream for a while; raise this if your platform cuts requests short.
230
+ // Seconds this route may run (your platform's limit applies). Passing it lets the agent stop calling tools at
231
+ // 75% of it and answer with what it has, instead of being cut off mid-stream.
231
232
  export const maxDuration = 60
232
233
 
233
- export const POST = createAgentHandler(agent)
234
+ export const POST = createAgentHandler(agent, { maxDuration })
234
235
  `;
235
236
  }
236
237
  // src/init/run.ts
@@ -366,7 +367,9 @@ async function runInit(options, io) {
366
367
  ["ai", "ai@^7"],
367
368
  ["zod", "zod@^4"],
368
369
  [info.packageName, `${info.packageName}@^4`],
369
- ["@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"]
370
373
  ].filter(([name]) => !project.dependencies[name]).map(([, spec]) => spec);
371
374
  if (missing.length > 0) {
372
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
  /**
@@ -131,12 +175,19 @@ interface ClientContext {
131
175
  locale?: string;
132
176
  page?: string;
133
177
  }
178
+ interface HandlerOptions {
179
+ /**
180
+ * The route's `maxDuration` in seconds (pass the exported constant). Once 75% of it has passed, the agent
181
+ * starts no further tool calls and answers with what it has, instead of being cut off by the platform.
182
+ */
183
+ maxDuration?: number;
184
+ }
134
185
  /**
135
186
  * Creates a `POST` route handler that speaks the AI SDK UI message stream protocol (`useChat`).
136
187
  *
137
188
  * Tools execute inside this request, so server functions they call see the caller's cookies.
138
189
  */
139
- export declare function createAgentHandler(config: AgentConfig): (request: Request) => Promise<Response>;
190
+ export declare function createAgentHandler(config: AgentConfig, options?: HandlerOptions): (request: Request) => Promise<Response>;
140
191
  /**
141
192
  * An error whose message is safe to show to the model (and, through it, the user).
142
193
  * Any other error thrown by a tool is reported via `onError` and replaced with a generic message,
@@ -159,5 +210,5 @@ interface ProviderInfo {
159
210
  }
160
211
  /** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
161
212
  export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
162
- export declare const VERSION = "0.2.3";
163
- export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, 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-CwDWliFC.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);
@@ -220,15 +249,20 @@ function errorResponse(status, message) {
220
249
  headers: JSON_HEADERS
221
250
  });
222
251
  }
252
+ /** Share of `maxDuration` after which no new tool calls start; the rest is left for the final answer. */
253
+ const TIME_BUDGET_SHARE = .75;
254
+ const TIME_UP = `Time for this request is almost up. Do not call any more tools. Answer now with what you have done and found so far. If work remains, say what is left so the user can ask you to continue.`;
223
255
  /**
224
256
  * Creates a `POST` route handler that speaks the AI SDK UI message stream protocol (`useChat`).
225
257
  *
226
258
  * Tools execute inside this request, so server functions they call see the caller's cookies.
227
259
  */
228
- function createAgentHandler(config) {
260
+ function createAgentHandler(config, options = {}) {
229
261
  const maxSteps = config.maxSteps ?? 10;
230
262
  const report = config.onError ?? ((error) => console.error("[dopus]", error));
231
263
  return async function handleAgentRequest(request) {
264
+ const started = Date.now();
265
+ const deadline = options.maxDuration ? Date.now() + options.maxDuration * 1e3 * TIME_BUDGET_SHARE : void 0;
232
266
  const maxBodyBytes = config.limits?.maxBodyBytes ?? 1e6;
233
267
  const maxMessageChars = config.limits?.maxMessageChars ?? 8e3;
234
268
  if (Number(request.headers.get("content-length") ?? 0) > maxBodyBytes) return errorResponse(413, "Conversation is too long");
@@ -243,12 +277,29 @@ function createAgentHandler(config) {
243
277
  const user = await config.identify?.(request);
244
278
  const { tools, toolApproval } = buildTools(config.tools, {
245
279
  user,
246
- report
280
+ report,
281
+ onToolCall: config.onToolCall
247
282
  });
248
283
  const validated = await safeValidateUIMessages({ messages: body?.messages });
249
284
  if (!validated.success) return errorResponse(400, "Invalid messages");
250
285
  const messages = validated.data;
251
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
+ }
252
303
  const client = readClientContext(body, request);
253
304
  const runtime = {
254
305
  ...client,
@@ -266,9 +317,41 @@ function createAgentHandler(config) {
266
317
  tools,
267
318
  toolApproval,
268
319
  stopWhen: isStepCount(maxSteps),
320
+ prepareStep: ({ stepNumber, instructions }) => {
321
+ if (deadline === void 0 || stepNumber === 0 || Date.now() < deadline) return {};
322
+ return {
323
+ toolChoice: "none",
324
+ instructions: [...(instructions === void 0 ? [] : [instructions].flat()).map((m) => typeof m === "string" ? {
325
+ role: "system",
326
+ content: m
327
+ } : m).filter((m) => m.content !== TIME_UP), {
328
+ role: "system",
329
+ content: TIME_UP
330
+ }]
331
+ };
332
+ },
269
333
  abortSignal: request.signal,
270
334
  ...approvalSecret ? { experimental_toolApprovalSecret: approvalSecret } : {},
271
- 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
+ }
272
355
  });
273
356
  const response = createUIMessageStreamResponse({ stream: toUIMessageStream({
274
357
  stream: result.stream,
@@ -27,5 +27,5 @@ const PROVIDERS = {
27
27
  }
28
28
  };
29
29
  // src/version.ts
30
- const VERSION = "0.2.3";
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.3",
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
  )}