dopus 0.2.8 → 0.2.10
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 +67 -2
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +21 -2
- package/dist/index.js +22 -3
- package/dist/{version-Bs8LB7Ph.js → version-CWmiLdVn.js} +1 -1
- package/package.json +1 -1
- package/templates/history.json +2 -1
- package/templates/ui/agent-chat.tsx +48 -0
package/README.md
CHANGED
|
@@ -224,13 +224,76 @@ Anthropic gets an explicit cache breakpoint after layer 3. OpenAI and Gemini cac
|
|
|
224
224
|
- `<AgentChat />`: the panel;
|
|
225
225
|
- `<AgentWidget />`: a floating launcher that opens the panel.
|
|
226
226
|
|
|
227
|
-
Both accept `api`, `suggestions`, `labels` (every UI string, for translation), `className`,
|
|
228
|
-
`maxJobFollowUps` for [background jobs](#background-jobs).
|
|
227
|
+
Both accept `api`, `suggestions`, `labels` (every UI string, for translation), `className`, `channel` /
|
|
228
|
+
`maxJobFollowUps` for [background jobs](#background-jobs), and `history` to [keep the chat](#chat-history).
|
|
229
229
|
|
|
230
230
|
The UI shows each tool call as it runs. When a reply finishes while the widget is closed (for example the agent
|
|
231
231
|
continuing after a [background job](#background-jobs)), the launcher shows a dot until it is opened (`labels.unread`
|
|
232
232
|
for screen readers; `onReply` if you want your own signal). It renders an approve/decline card for confirmed tools and continues automatically once the user answers. When a tool has run, it calls `router.refresh()` so server components show the changed data. Closing the widget keeps the conversation, and a "New chat" button clears it. Assistant replies render as Markdown (tables, lists, code) through `react-markdown` with `remark-gfm`, which `init` installs. Raw HTML is never rendered, images show only their alt text, and links keep only http(s), mailto and relative URLs. If a request is over the size limit (413), the UI says so and offers a new chat instead of a useless retry. It also has stop and retry buttons, and it does not send on Enter while an IME (Korean, Japanese, Chinese) is still composing.
|
|
233
233
|
|
|
234
|
+
### Chat history
|
|
235
|
+
|
|
236
|
+
By default the conversation lives in memory: a reload starts a new chat. To keep it, pass `history` with `load`
|
|
237
|
+
(called once on mount) and `save` (called when the chat settles, and with `[]` on "New chat"). Where it goes is up to
|
|
238
|
+
you. Both hold functions, so set them up in a client component.
|
|
239
|
+
|
|
240
|
+
In this tab only (sessionStorage), keyed by user so that after a sign-out the next user in the tab does not see it:
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
// components/agent/agent.tsx
|
|
244
|
+
'use client'
|
|
245
|
+
import { useMemo } from 'react'
|
|
246
|
+
import { type AgentChatHistory, type AgentChatProps, AgentWidget } from './agent-chat'
|
|
247
|
+
|
|
248
|
+
const sessionHistory = (key: string): AgentChatHistory => ({
|
|
249
|
+
load: () => JSON.parse(sessionStorage.getItem(key) ?? '[]'),
|
|
250
|
+
save: (messages) => sessionStorage.setItem(key, JSON.stringify(messages)),
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
export function Agent({ userId, ...props }: AgentChatProps & { userId: string }) {
|
|
254
|
+
const history = useMemo(() => sessionHistory(`dopus-chat:${userId}`), [userId])
|
|
255
|
+
return <AgentWidget {...props} history={history} />
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
In your database: save on the server, where you know the user, and load from the client. `onFinish` gives you who
|
|
260
|
+
(`user`, from `identify`), which conversation (`conversationId`) and what this request added (`newMessages`: the user's
|
|
261
|
+
message and the agent's reply, with tool calls and results inside).
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
// agent.config.ts (needs `identify`, so `user` is set)
|
|
265
|
+
onFinish: async ({ user, conversationId, newMessages }) => {
|
|
266
|
+
if (!user) return
|
|
267
|
+
for (const message of newMessages) {
|
|
268
|
+
// Upsert by id: after an approval the reply arrives again, continued. Key by user too, since
|
|
269
|
+
// conversationId comes from the browser.
|
|
270
|
+
await db.chatMessage.upsert({ id: message.id, userId: user.id, conversationId, message })
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
// components/agent/agent.tsx: loadChat is your server action returning the latest conversation's messages
|
|
277
|
+
;<AgentWidget history={{ load: () => loadChat() }} />
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
- **`conversationId`** is the id of the conversation's first message: the same after a reload (when `history.load`
|
|
281
|
+
restores it), new after "New chat". A list of past conversations is yours to build from it.
|
|
282
|
+
- **`newMessages`** rather than the whole history: the browser sends the whole conversation with every request, so
|
|
283
|
+
saving only what this request added keeps earlier stored messages out of its reach. `messages` (the whole
|
|
284
|
+
conversation) is there too if one snapshot per conversation is enough.
|
|
285
|
+
- **Stopped or failed turns** do not reach `onFinish`. To keep the user's message anyway, save it in `beforeRequest`
|
|
286
|
+
(it gets `user` and `messages`).
|
|
287
|
+
|
|
288
|
+
Things to know:
|
|
289
|
+
|
|
290
|
+
- **Tool results are stored too.** Whatever your tools returned (orders, names) is in the saved conversation. Keep it
|
|
291
|
+
per user, and clear it on sign-out if the device may be shared.
|
|
292
|
+
- **It is still one conversation.** The browser sends the whole history with each message, so a long chat reaches the
|
|
293
|
+
size limit (413) and the UI offers a new chat. There is no list of past chats.
|
|
294
|
+
- **Background jobs across a reload.** A job that finishes while the page is reloading is not announced again, so its
|
|
295
|
+
"working in the background" line stays. The user can ask, and the agent reads the result with your status tool.
|
|
296
|
+
|
|
234
297
|
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.
|
|
235
298
|
|
|
236
299
|
## Configuration
|
|
@@ -285,6 +348,7 @@ defineAgent({
|
|
|
285
348
|
},
|
|
286
349
|
|
|
287
350
|
// Once per request, when the reply is done: tokens summed over all steps.
|
|
351
|
+
// also gets conversationId, newMessages and messages, to save the chat (see "Chat history")
|
|
288
352
|
onFinish: async ({ user, model, usage, steps, finishReason, durationMs }) => {
|
|
289
353
|
await db.usage.insert({ userId: user?.id, model: model.modelId, ...usage })
|
|
290
354
|
},
|
|
@@ -521,6 +585,7 @@ Planned next, within the lightweight scope above:
|
|
|
521
585
|
pnpm install
|
|
522
586
|
pnpm build && pnpm lint && pnpm typecheck && pnpm test
|
|
523
587
|
pnpm example:build && pnpm example:e2e # next start + scripted model
|
|
588
|
+
pnpm example:ui # the chat UI in Chromium (Playwright): approvals, jobs, limits
|
|
524
589
|
pnpm example:live # real model from examples/next-app/.env.local (costs cents)
|
|
525
590
|
pnpm smoke # packed tarball → fresh app → init (older release) → update → next build
|
|
526
591
|
pnpm templates:history # after changing a template: record its hash for `dopus update`
|
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-CWmiLdVn.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";
|
package/dist/index.d.ts
CHANGED
|
@@ -111,7 +111,10 @@ interface AgentConfig<TOOLS extends DopusToolSet = DopusToolSet> {
|
|
|
111
111
|
}) => Response | undefined | Promise<Response | undefined>;
|
|
112
112
|
/** Called once per tool call: ran (`success` / `error`) or declined by the user (`denied`). For audit logs. */
|
|
113
113
|
onToolCall?: (event: ToolCallEvent) => void | Promise<void>;
|
|
114
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* Called once per request when the reply is finished: tokens, steps, model (usage tracking), and the conversation
|
|
116
|
+
* (to save chat history in your database: `user`, `conversationId`, `newMessages`).
|
|
117
|
+
*/
|
|
115
118
|
onFinish?: (event: FinishEvent) => void | Promise<void>;
|
|
116
119
|
/** Maximum model steps (tool round trips) per request. Default 10. */
|
|
117
120
|
maxSteps?: number;
|
|
@@ -169,6 +172,22 @@ interface FinishEvent {
|
|
|
169
172
|
steps: number;
|
|
170
173
|
finishReason: string;
|
|
171
174
|
durationMs: number;
|
|
175
|
+
/**
|
|
176
|
+
* Which conversation this is: the id of its first message. The same across reloads (when the chat UI restores
|
|
177
|
+
* its `history`), new after "New chat". Sent by the browser, so store it under `user`, never on its own.
|
|
178
|
+
*/
|
|
179
|
+
conversationId: string;
|
|
180
|
+
/**
|
|
181
|
+
* What this request added or changed: the user's message (when the request started with one) and the
|
|
182
|
+
* assistant's reply. After an approval the reply is the same message, continued, so upsert by `id`.
|
|
183
|
+
* Saving only these keeps earlier stored messages out of the browser's reach.
|
|
184
|
+
*/
|
|
185
|
+
newMessages: UIMessage[];
|
|
186
|
+
/**
|
|
187
|
+
* The whole conversation after this reply, as the chat UI holds it. Handy to store one snapshot per
|
|
188
|
+
* conversation; it comes from the browser, so it can differ from what you stored earlier.
|
|
189
|
+
*/
|
|
190
|
+
messages: UIMessage[];
|
|
172
191
|
}
|
|
173
192
|
/** Identity function that gives `agent.config.ts` type checking. */
|
|
174
193
|
export declare function defineAgent<TOOLS extends DopusToolSet>(config: AgentConfig<TOOLS>): AgentConfig<TOOLS>;
|
|
@@ -216,5 +235,5 @@ interface ProviderInfo {
|
|
|
216
235
|
}
|
|
217
236
|
/** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
|
|
218
237
|
export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
|
|
219
|
-
export declare const VERSION = "0.2.
|
|
238
|
+
export declare const VERSION = "0.2.10";
|
|
220
239
|
export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, FinishEvent, HandlerOptions, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolCallEvent, ToolContext, ToolOptions };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as PROVIDERS, t as VERSION } from "./version-
|
|
1
|
+
import { n as PROVIDERS, t as VERSION } from "./version-CWmiLdVn.js";
|
|
2
2
|
import { convertToModelMessages, createUIMessageStreamResponse, getToolName, isStepCount, isToolUIPart, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
|
|
3
3
|
// src/job.ts
|
|
4
4
|
/**
|
|
@@ -378,6 +378,14 @@ function createAgentHandler(config, options = {}) {
|
|
|
378
378
|
})
|
|
379
379
|
};
|
|
380
380
|
const approvalSecret = config.approvalSecret ?? process.env.DOPUS_APPROVAL_SECRET;
|
|
381
|
+
const finish = { async emit() {
|
|
382
|
+
if (this.done || !this.usage || !this.conversation) return;
|
|
383
|
+
this.done = true;
|
|
384
|
+
await runHook(config.onFinish, {
|
|
385
|
+
...this.usage,
|
|
386
|
+
...this.conversation
|
|
387
|
+
}, report);
|
|
388
|
+
} };
|
|
381
389
|
const result = streamText({
|
|
382
390
|
...config.callOptions,
|
|
383
391
|
model: config.model,
|
|
@@ -410,7 +418,7 @@ function createAgentHandler(config, options = {}) {
|
|
|
410
418
|
onError: ({ error }) => report(error),
|
|
411
419
|
onEnd: async (event) => {
|
|
412
420
|
await config.callOptions?.onEnd?.(event);
|
|
413
|
-
|
|
421
|
+
finish.usage = {
|
|
414
422
|
user,
|
|
415
423
|
model: {
|
|
416
424
|
provider: event.model.provider,
|
|
@@ -425,13 +433,24 @@ function createAgentHandler(config, options = {}) {
|
|
|
425
433
|
steps: event.stepNumber + 1,
|
|
426
434
|
finishReason: event.finishReason,
|
|
427
435
|
durationMs: Date.now() - started
|
|
428
|
-
}
|
|
436
|
+
};
|
|
437
|
+
await finish.emit();
|
|
429
438
|
}
|
|
430
439
|
});
|
|
431
440
|
const response = createUIMessageStreamResponse({ stream: toUIMessageStream({
|
|
432
441
|
stream: result.stream,
|
|
433
442
|
tools,
|
|
434
443
|
originalMessages: messages,
|
|
444
|
+
onEnd: async ({ messages: final, responseMessage, isAborted }) => {
|
|
445
|
+
if (isAborted) return;
|
|
446
|
+
const last = messages.at(-1);
|
|
447
|
+
finish.conversation = {
|
|
448
|
+
conversationId: messages[0]?.id ?? responseMessage.id,
|
|
449
|
+
newMessages: last?.role === "user" ? [last, responseMessage] : [responseMessage],
|
|
450
|
+
messages: final
|
|
451
|
+
};
|
|
452
|
+
await finish.emit();
|
|
453
|
+
},
|
|
435
454
|
onError: (error) => {
|
|
436
455
|
if (error instanceof ToolError) return error.message;
|
|
437
456
|
report(error);
|
package/package.json
CHANGED
package/templates/history.json
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
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
7
|
* Background jobs: pass a `channel` and the agent continues by itself when a job it started finishes.
|
|
8
|
+
* Chat history: pass `history` to keep the conversation across reloads (sessionStorage, your API...).
|
|
8
9
|
*/
|
|
9
10
|
import { useChat } from '@ai-sdk/react'
|
|
10
11
|
import {
|
|
@@ -76,6 +77,16 @@ export interface AgentChatProps {
|
|
|
76
77
|
maxJobFollowUps?: number
|
|
77
78
|
/** Called when the agent finishes a reply. `AgentWidget` uses it to mark the closed launcher as unread. */
|
|
78
79
|
onReply?: () => void
|
|
80
|
+
/** Keep the conversation across reloads. Off by default (the chat lives in memory). */
|
|
81
|
+
history?: AgentChatHistory
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Where the conversation is kept. Both are optional; neither may throw away the user's data silently. */
|
|
85
|
+
export interface AgentChatHistory {
|
|
86
|
+
/** Called once when the chat mounts; return the conversation to restore. */
|
|
87
|
+
load?: () => UIMessage[] | null | undefined | Promise<UIMessage[] | null | undefined>
|
|
88
|
+
/** Called when the chat settles after a change, and with `[]` on "New chat". */
|
|
89
|
+
save?: (messages: UIMessage[]) => void | Promise<void>
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
export interface AgentChannel {
|
|
@@ -298,6 +309,7 @@ export function AgentChat({
|
|
|
298
309
|
channel,
|
|
299
310
|
maxJobFollowUps = 3,
|
|
300
311
|
onReply,
|
|
312
|
+
history,
|
|
301
313
|
}: AgentChatProps) {
|
|
302
314
|
const labels = { ...DEFAULT_LABELS, ...labelOverrides }
|
|
303
315
|
const router = useRouter()
|
|
@@ -340,6 +352,39 @@ export function AgentChat({
|
|
|
340
352
|
const listRef = useRef<HTMLDivElement>(null)
|
|
341
353
|
const busy = status === 'submitted' || status === 'streaming'
|
|
342
354
|
|
|
355
|
+
// Chat history: restore once on mount (after hydration), then save whenever the chat settles.
|
|
356
|
+
const historyRef = useRef(history)
|
|
357
|
+
useEffect(() => {
|
|
358
|
+
historyRef.current = history
|
|
359
|
+
})
|
|
360
|
+
const [restored, setRestored] = useState(!history?.load)
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
const load = historyRef.current?.load
|
|
363
|
+
if (!load) return
|
|
364
|
+
let cancelled = false
|
|
365
|
+
Promise.resolve()
|
|
366
|
+
.then(load)
|
|
367
|
+
.then((saved) => {
|
|
368
|
+
// Keep anything the user already typed while it was loading.
|
|
369
|
+
if (!cancelled && saved?.length)
|
|
370
|
+
setMessages((current) => (current.length ? current : saved))
|
|
371
|
+
})
|
|
372
|
+
.catch((error) => console.error('[dopus] history.load failed', error))
|
|
373
|
+
.finally(() => {
|
|
374
|
+
if (!cancelled) setRestored(true)
|
|
375
|
+
})
|
|
376
|
+
return () => {
|
|
377
|
+
cancelled = true
|
|
378
|
+
}
|
|
379
|
+
}, [setMessages])
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
// Empty is saved only by "New chat", so a slow or failed load never overwrites what was stored.
|
|
382
|
+
if (!restored || status !== 'ready' || messages.length === 0) return
|
|
383
|
+
Promise.resolve(historyRef.current?.save?.(messages)).catch((error) =>
|
|
384
|
+
console.error('[dopus] history.save failed', error),
|
|
385
|
+
)
|
|
386
|
+
}, [restored, status, messages])
|
|
387
|
+
|
|
343
388
|
// Jobs the channel reported finished. Kept even before this chat shows the job (a job can finish before
|
|
344
389
|
// its tool output has streamed in); only IDs this chat started are acted on.
|
|
345
390
|
const [finishedJobs, setFinishedJobs] = useState<Set<string>>(() => new Set())
|
|
@@ -428,6 +473,9 @@ export function AgentChat({
|
|
|
428
473
|
clearError()
|
|
429
474
|
setMessages([])
|
|
430
475
|
setInput('')
|
|
476
|
+
Promise.resolve(historyRef.current?.save?.([])).catch((error) =>
|
|
477
|
+
console.error('[dopus] history.save failed', error),
|
|
478
|
+
)
|
|
431
479
|
}
|
|
432
480
|
|
|
433
481
|
// The route answers 413 when the history is over its size limit; retrying cannot help then.
|