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 +50 -9
- package/dist/cli.js +4 -2
- package/dist/index.d.ts +47 -3
- package/dist/index.js +71 -5
- package/dist/{version-CNF5aJ7W.js → version-DmrGrSmM.js} +1 -1
- package/package.json +3 -1
- package/templates/ui/agent-chat.css +92 -0
- package/templates/ui/agent-chat.tsx +98 -19
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
|
-
|
|
|
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-
|
|
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.
|
|
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-
|
|
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
|
-
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dopus",
|
|
3
|
-
"version": "0.2.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
)
|
|
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 {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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">
|
|
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
|
|
257
|
-
|
|
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
|
)}
|