dopus 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +223 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +492 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.js +231 -0
- package/dist/version-BPyQRLlj.js +31 -0
- package/package.json +75 -4
- package/templates/prompts/app.ts +18 -0
- package/templates/prompts/presets/customer-support.ts +13 -0
- package/templates/prompts/presets/general.ts +12 -0
- package/templates/prompts/presets/internal-ops.ts +13 -0
- package/templates/ui/agent-chat.module.css +255 -0
- package/templates/ui/agent-chat.tsx +294 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Chat UI for the dopus agent. Copied by `dopus init`; this file is yours to edit.
|
|
5
|
+
* Talks to POST /api/agent with the AI SDK `useChat` protocol. Styles: ./agent-chat.module.css
|
|
6
|
+
*/
|
|
7
|
+
import { useChat } from '@ai-sdk/react'
|
|
8
|
+
import {
|
|
9
|
+
DefaultChatTransport,
|
|
10
|
+
getToolName,
|
|
11
|
+
isToolUIPart,
|
|
12
|
+
lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
13
|
+
type UIMessage,
|
|
14
|
+
} from 'ai'
|
|
15
|
+
import { type FormEvent, type KeyboardEvent, useEffect, useRef, useState } from 'react'
|
|
16
|
+
import styles from './agent-chat.module.css'
|
|
17
|
+
|
|
18
|
+
const DEFAULT_LABELS = {
|
|
19
|
+
title: 'Assistant',
|
|
20
|
+
placeholder: 'Ask anything…',
|
|
21
|
+
send: 'Send',
|
|
22
|
+
stop: 'Stop',
|
|
23
|
+
retry: 'Retry',
|
|
24
|
+
error: 'Something went wrong.',
|
|
25
|
+
thinking: 'Thinking…',
|
|
26
|
+
running: 'Running',
|
|
27
|
+
needsApproval: 'Needs your approval',
|
|
28
|
+
approve: 'Approve',
|
|
29
|
+
decline: 'Decline',
|
|
30
|
+
approved: 'Approved',
|
|
31
|
+
declined: 'Declined',
|
|
32
|
+
failed: 'Failed',
|
|
33
|
+
details: 'Details',
|
|
34
|
+
open: 'Open assistant',
|
|
35
|
+
close: 'Close assistant',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type AgentChatLabels = typeof DEFAULT_LABELS
|
|
39
|
+
|
|
40
|
+
export interface AgentChatProps {
|
|
41
|
+
/** Route created by `dopus init`. */
|
|
42
|
+
api?: string
|
|
43
|
+
labels?: Partial<AgentChatLabels>
|
|
44
|
+
/** Example prompts shown before the first message. */
|
|
45
|
+
suggestions?: string[]
|
|
46
|
+
className?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type ToolPart = Extract<UIMessage['parts'][number], { toolCallId: string }>
|
|
50
|
+
|
|
51
|
+
/** `cancelOrder` → `Cancel order` */
|
|
52
|
+
function humanize(name: string) {
|
|
53
|
+
const words = name
|
|
54
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
55
|
+
.replace(/[_-]+/g, ' ')
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function Json({ value }: { value: unknown }) {
|
|
61
|
+
return <pre className={styles.json}>{JSON.stringify(value, null, 2)}</pre>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ToolCall({
|
|
65
|
+
part,
|
|
66
|
+
labels,
|
|
67
|
+
onRespond,
|
|
68
|
+
}: {
|
|
69
|
+
part: ToolPart
|
|
70
|
+
labels: AgentChatLabels
|
|
71
|
+
onRespond: (approvalId: string, approved: boolean) => void
|
|
72
|
+
}) {
|
|
73
|
+
const name = humanize(getToolName(part))
|
|
74
|
+
|
|
75
|
+
switch (part.state) {
|
|
76
|
+
case 'input-streaming':
|
|
77
|
+
case 'input-available':
|
|
78
|
+
return (
|
|
79
|
+
<div className={styles.tool} data-state="running">
|
|
80
|
+
<span className={styles.spinner} aria-hidden /> {labels.running}: {name}
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
case 'approval-requested':
|
|
84
|
+
return (
|
|
85
|
+
<fieldset className={styles.approval}>
|
|
86
|
+
<legend className={styles.approvalTitle}>
|
|
87
|
+
{name} · {labels.needsApproval}
|
|
88
|
+
</legend>
|
|
89
|
+
<Json value={part.input} />
|
|
90
|
+
<div className={styles.approvalActions}>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
className={styles.primary}
|
|
94
|
+
onClick={() => onRespond(part.approval.id, true)}
|
|
95
|
+
>
|
|
96
|
+
{labels.approve}
|
|
97
|
+
</button>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
className={styles.secondary}
|
|
101
|
+
onClick={() => onRespond(part.approval.id, false)}
|
|
102
|
+
>
|
|
103
|
+
{labels.decline}
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
</fieldset>
|
|
107
|
+
)
|
|
108
|
+
case 'approval-responded':
|
|
109
|
+
return (
|
|
110
|
+
<div className={styles.tool} data-state={part.approval.approved ? 'running' : 'declined'}>
|
|
111
|
+
{part.approval.approved ? `${labels.approved}: ${name}` : `${labels.declined}: ${name}`}
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
case 'output-available':
|
|
115
|
+
return (
|
|
116
|
+
<details className={styles.tool} data-state="done">
|
|
117
|
+
<summary>✓ {name}</summary>
|
|
118
|
+
<Json value={part.output} />
|
|
119
|
+
</details>
|
|
120
|
+
)
|
|
121
|
+
case 'output-error':
|
|
122
|
+
return (
|
|
123
|
+
<div className={styles.tool} data-state="error">
|
|
124
|
+
✗ {name} · {labels.failed}
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
case 'output-denied':
|
|
128
|
+
return (
|
|
129
|
+
<div className={styles.tool} data-state="declined">
|
|
130
|
+
{labels.declined}: {name}
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
default:
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function Message({
|
|
139
|
+
message,
|
|
140
|
+
labels,
|
|
141
|
+
onRespond,
|
|
142
|
+
}: {
|
|
143
|
+
message: UIMessage
|
|
144
|
+
labels: AgentChatLabels
|
|
145
|
+
onRespond: (approvalId: string, approved: boolean) => void
|
|
146
|
+
}) {
|
|
147
|
+
return (
|
|
148
|
+
<div className={styles.message} data-role={message.role}>
|
|
149
|
+
{message.parts.map((part, index) => {
|
|
150
|
+
const key = `${message.id}-${index}`
|
|
151
|
+
if (part.type === 'text') {
|
|
152
|
+
return part.text ? (
|
|
153
|
+
<div key={key} className={styles.text}>
|
|
154
|
+
{part.text}
|
|
155
|
+
</div>
|
|
156
|
+
) : null
|
|
157
|
+
}
|
|
158
|
+
if (isToolUIPart(part)) {
|
|
159
|
+
return <ToolCall key={key} part={part} labels={labels} onRespond={onRespond} />
|
|
160
|
+
}
|
|
161
|
+
return null
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function AgentChat({
|
|
168
|
+
api = '/api/agent',
|
|
169
|
+
labels: labelOverrides,
|
|
170
|
+
suggestions = [],
|
|
171
|
+
className,
|
|
172
|
+
}: AgentChatProps) {
|
|
173
|
+
const labels = { ...DEFAULT_LABELS, ...labelOverrides }
|
|
174
|
+
const [transport] = useState(
|
|
175
|
+
() =>
|
|
176
|
+
new DefaultChatTransport({
|
|
177
|
+
api,
|
|
178
|
+
// Untrusted hints for the prompt; the server only accepts `locale` and `page`.
|
|
179
|
+
body: () => ({ context: { page: window.location.pathname, locale: navigator.language } }),
|
|
180
|
+
}),
|
|
181
|
+
)
|
|
182
|
+
const { messages, sendMessage, status, stop, error, regenerate, addToolApprovalResponse } =
|
|
183
|
+
useChat({
|
|
184
|
+
transport,
|
|
185
|
+
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
186
|
+
})
|
|
187
|
+
const [input, setInput] = useState('')
|
|
188
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
189
|
+
const busy = status === 'submitted' || status === 'streaming'
|
|
190
|
+
|
|
191
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll whenever messages change
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' })
|
|
194
|
+
}, [messages])
|
|
195
|
+
|
|
196
|
+
function send(text: string) {
|
|
197
|
+
const trimmed = text.trim()
|
|
198
|
+
if (!trimmed || busy) return
|
|
199
|
+
sendMessage({ text: trimmed })
|
|
200
|
+
setInput('')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function onSubmit(event: FormEvent) {
|
|
204
|
+
event.preventDefault()
|
|
205
|
+
send(input)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
|
209
|
+
// Enter sends, Shift+Enter adds a line. Ignore Enter while an IME (Korean, Japanese…) is composing.
|
|
210
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
211
|
+
event.preventDefault()
|
|
212
|
+
send(input)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const respond = (id: string, approved: boolean) => addToolApprovalResponse({ id, approved })
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<section
|
|
220
|
+
className={[styles.chat, className].filter(Boolean).join(' ')}
|
|
221
|
+
aria-label={labels.title}
|
|
222
|
+
>
|
|
223
|
+
<header className={styles.header}>{labels.title}</header>
|
|
224
|
+
<div ref={listRef} className={styles.messages} aria-live="polite">
|
|
225
|
+
{messages.length === 0 && suggestions.length > 0 && (
|
|
226
|
+
<div className={styles.suggestions}>
|
|
227
|
+
{suggestions.map((text) => (
|
|
228
|
+
<button
|
|
229
|
+
key={text}
|
|
230
|
+
type="button"
|
|
231
|
+
className={styles.suggestion}
|
|
232
|
+
onClick={() => send(text)}
|
|
233
|
+
>
|
|
234
|
+
{text}
|
|
235
|
+
</button>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
{messages.map((message) => (
|
|
240
|
+
<Message key={message.id} message={message} labels={labels} onRespond={respond} />
|
|
241
|
+
))}
|
|
242
|
+
{status === 'submitted' && <div className={styles.thinking}>{labels.thinking}</div>}
|
|
243
|
+
{error && (
|
|
244
|
+
<div className={styles.error} role="alert">
|
|
245
|
+
{labels.error}{' '}
|
|
246
|
+
<button type="button" className={styles.link} onClick={() => regenerate()}>
|
|
247
|
+
{labels.retry}
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
<form className={styles.form} onSubmit={onSubmit}>
|
|
253
|
+
<textarea
|
|
254
|
+
className={styles.input}
|
|
255
|
+
value={input}
|
|
256
|
+
onChange={(event) => setInput(event.target.value)}
|
|
257
|
+
onKeyDown={onKeyDown}
|
|
258
|
+
placeholder={labels.placeholder}
|
|
259
|
+
rows={1}
|
|
260
|
+
aria-label={labels.placeholder}
|
|
261
|
+
/>
|
|
262
|
+
{busy ? (
|
|
263
|
+
<button type="button" className={styles.secondary} onClick={() => stop()}>
|
|
264
|
+
{labels.stop}
|
|
265
|
+
</button>
|
|
266
|
+
) : (
|
|
267
|
+
<button type="submit" className={styles.primary} disabled={!input.trim()}>
|
|
268
|
+
{labels.send}
|
|
269
|
+
</button>
|
|
270
|
+
)}
|
|
271
|
+
</form>
|
|
272
|
+
</section>
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Floating button that opens the chat in a panel. Put it once in your root layout. */
|
|
277
|
+
export function AgentWidget(props: AgentChatProps) {
|
|
278
|
+
const [open, setOpen] = useState(false)
|
|
279
|
+
const labels = { ...DEFAULT_LABELS, ...props.labels }
|
|
280
|
+
return (
|
|
281
|
+
<div className={styles.widget}>
|
|
282
|
+
{open && <AgentChat {...props} className={styles.panel} />}
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
className={styles.launcher}
|
|
286
|
+
aria-expanded={open}
|
|
287
|
+
aria-label={open ? labels.close : labels.open}
|
|
288
|
+
onClick={() => setOpen((value) => !value)}
|
|
289
|
+
>
|
|
290
|
+
{open ? '×' : '✦'}
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
)
|
|
294
|
+
}
|