dopus 0.2.8 → 0.2.9
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 +59 -2
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.js +17 -3
- package/dist/{version-Bs8LB7Ph.js → version-NYbd20Zw.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,68 @@ 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.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
// agent.config.ts
|
|
263
|
+
onFinish: async ({ user, messages }) => {
|
|
264
|
+
if (user) await db.chat.upsert({ userId: user.id, messages }) // needs `identify`
|
|
265
|
+
},
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
// components/agent/agent.tsx: loadChat is your server action returning the saved messages
|
|
270
|
+
;<AgentWidget
|
|
271
|
+
history={{
|
|
272
|
+
load: () => loadChat(),
|
|
273
|
+
save: (m) => {
|
|
274
|
+
if (m.length === 0) clearChat()
|
|
275
|
+
},
|
|
276
|
+
}}
|
|
277
|
+
/>
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Things to know:
|
|
281
|
+
|
|
282
|
+
- **Tool results are stored too.** Whatever your tools returned (orders, names) is in the saved conversation. Keep it
|
|
283
|
+
per user, and clear it on sign-out if the device may be shared.
|
|
284
|
+
- **It is still one conversation.** The browser sends the whole history with each message, so a long chat reaches the
|
|
285
|
+
size limit (413) and the UI offers a new chat. There is no list of past chats.
|
|
286
|
+
- **Background jobs across a reload.** A job that finishes while the page is reloading is not announced again, so its
|
|
287
|
+
"working in the background" line stays. The user can ask, and the agent reads the result with your status tool.
|
|
288
|
+
|
|
234
289
|
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
290
|
|
|
236
291
|
## Configuration
|
|
@@ -285,6 +340,7 @@ defineAgent({
|
|
|
285
340
|
},
|
|
286
341
|
|
|
287
342
|
// Once per request, when the reply is done: tokens summed over all steps.
|
|
343
|
+
// also gets `messages`: the whole conversation after this reply (see "Chat history")
|
|
288
344
|
onFinish: async ({ user, model, usage, steps, finishReason, durationMs }) => {
|
|
289
345
|
await db.usage.insert({ userId: user?.id, model: model.modelId, ...usage })
|
|
290
346
|
},
|
|
@@ -521,6 +577,7 @@ Planned next, within the lightweight scope above:
|
|
|
521
577
|
pnpm install
|
|
522
578
|
pnpm build && pnpm lint && pnpm typecheck && pnpm test
|
|
523
579
|
pnpm example:build && pnpm example:e2e # next start + scripted model
|
|
580
|
+
pnpm example:ui # the chat UI in Chromium (Playwright): approvals, jobs, limits
|
|
524
581
|
pnpm example:live # real model from examples/next-app/.env.local (costs cents)
|
|
525
582
|
pnpm smoke # packed tarball → fresh app → init (older release) → update → next build
|
|
526
583
|
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-NYbd20Zw.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 whole
|
|
116
|
+
* conversation including the reply (to save chat history in your database).
|
|
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,11 @@ interface FinishEvent {
|
|
|
169
172
|
steps: number;
|
|
170
173
|
finishReason: string;
|
|
171
174
|
durationMs: number;
|
|
175
|
+
/**
|
|
176
|
+
* The conversation after this reply, as the chat UI holds it (`UIMessage[]`). Save it to restore the chat later
|
|
177
|
+
* (the chat UI's `history.load`). It came from the browser, so treat it as the user's own data, not as trusted.
|
|
178
|
+
*/
|
|
179
|
+
messages: UIMessage[];
|
|
172
180
|
}
|
|
173
181
|
/** Identity function that gives `agent.config.ts` type checking. */
|
|
174
182
|
export declare function defineAgent<TOOLS extends DopusToolSet>(config: AgentConfig<TOOLS>): AgentConfig<TOOLS>;
|
|
@@ -216,5 +224,5 @@ interface ProviderInfo {
|
|
|
216
224
|
}
|
|
217
225
|
/** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
|
|
218
226
|
export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
|
|
219
|
-
export declare const VERSION = "0.2.
|
|
227
|
+
export declare const VERSION = "0.2.9";
|
|
220
228
|
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-NYbd20Zw.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.messages) return;
|
|
383
|
+
this.done = true;
|
|
384
|
+
await runHook(config.onFinish, {
|
|
385
|
+
...this.usage,
|
|
386
|
+
messages: this.messages
|
|
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,19 @@ 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, isAborted }) => {
|
|
445
|
+
if (isAborted) return;
|
|
446
|
+
finish.messages = final;
|
|
447
|
+
await finish.emit();
|
|
448
|
+
},
|
|
435
449
|
onError: (error) => {
|
|
436
450
|
if (error instanceof ToolError) return error.message;
|
|
437
451
|
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.
|