dopus 0.0.0-stage → 0.2.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.
@@ -0,0 +1,255 @@
1
+ /* Styles for agent-chat.tsx. Copied by `dopus init`; yours to edit. Theme with the --dopus-* variables. */
2
+ .chat {
3
+ --dopus-bg: #ffffff;
4
+ --dopus-fg: #18181b;
5
+ --dopus-muted: #71717a;
6
+ --dopus-border: #e4e4e7;
7
+ --dopus-surface: #f4f4f5;
8
+ --dopus-accent: #18181b;
9
+ --dopus-accent-fg: #ffffff;
10
+ --dopus-danger: #b91c1c;
11
+ --dopus-radius: 12px;
12
+
13
+ display: flex;
14
+ flex-direction: column;
15
+ height: 100%;
16
+ min-height: 320px;
17
+ background: var(--dopus-bg);
18
+ color: var(--dopus-fg);
19
+ border: 1px solid var(--dopus-border);
20
+ border-radius: var(--dopus-radius);
21
+ font:
22
+ 14px / 1.5 system-ui,
23
+ -apple-system,
24
+ "Segoe UI",
25
+ sans-serif;
26
+ overflow: hidden;
27
+ }
28
+
29
+ @media (prefers-color-scheme: dark) {
30
+ .chat {
31
+ --dopus-bg: #18181b;
32
+ --dopus-fg: #f4f4f5;
33
+ --dopus-muted: #a1a1aa;
34
+ --dopus-border: #3f3f46;
35
+ --dopus-surface: #27272a;
36
+ --dopus-accent: #f4f4f5;
37
+ --dopus-accent-fg: #18181b;
38
+ --dopus-danger: #f87171;
39
+ }
40
+ }
41
+
42
+ .header {
43
+ padding: 12px 16px;
44
+ font-weight: 600;
45
+ border-bottom: 1px solid var(--dopus-border);
46
+ }
47
+
48
+ .messages {
49
+ flex: 1;
50
+ overflow-y: auto;
51
+ padding: 16px;
52
+ display: flex;
53
+ flex-direction: column;
54
+ gap: 12px;
55
+ }
56
+
57
+ .message {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 6px;
61
+ max-width: 85%;
62
+ }
63
+
64
+ .message[data-role="user"] {
65
+ align-self: flex-end;
66
+ align-items: flex-end;
67
+ }
68
+
69
+ .text {
70
+ white-space: pre-wrap;
71
+ overflow-wrap: anywhere;
72
+ }
73
+
74
+ .message[data-role="user"] .text {
75
+ background: var(--dopus-accent);
76
+ color: var(--dopus-accent-fg);
77
+ padding: 8px 12px;
78
+ border-radius: var(--dopus-radius);
79
+ }
80
+
81
+ .tool {
82
+ font-size: 13px;
83
+ color: var(--dopus-muted);
84
+ background: var(--dopus-surface);
85
+ border-radius: 8px;
86
+ padding: 6px 10px;
87
+ }
88
+
89
+ .tool summary {
90
+ cursor: pointer;
91
+ }
92
+
93
+ .tool[data-state="error"] {
94
+ color: var(--dopus-danger);
95
+ }
96
+
97
+ .approval {
98
+ margin: 0;
99
+ min-inline-size: 0;
100
+ border: 1px solid var(--dopus-border);
101
+ border-radius: var(--dopus-radius);
102
+ padding: 12px;
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 8px;
106
+ }
107
+
108
+ .approvalTitle {
109
+ float: left;
110
+ width: 100%;
111
+ padding: 0;
112
+ font-weight: 600;
113
+ }
114
+
115
+ .approvalActions {
116
+ display: flex;
117
+ gap: 8px;
118
+ }
119
+
120
+ .json {
121
+ margin: 0;
122
+ font:
123
+ 12px / 1.4 ui-monospace,
124
+ SFMono-Regular,
125
+ Menlo,
126
+ monospace;
127
+ white-space: pre-wrap;
128
+ overflow-wrap: anywhere;
129
+ max-height: 200px;
130
+ overflow: auto;
131
+ }
132
+
133
+ .thinking {
134
+ color: var(--dopus-muted);
135
+ }
136
+
137
+ .error {
138
+ color: var(--dopus-danger);
139
+ }
140
+
141
+ .suggestions {
142
+ display: flex;
143
+ flex-wrap: wrap;
144
+ gap: 8px;
145
+ }
146
+
147
+ .suggestion {
148
+ border: 1px solid var(--dopus-border);
149
+ background: transparent;
150
+ color: inherit;
151
+ border-radius: 999px;
152
+ padding: 6px 12px;
153
+ cursor: pointer;
154
+ font: inherit;
155
+ }
156
+
157
+ .form {
158
+ display: flex;
159
+ gap: 8px;
160
+ padding: 12px;
161
+ border-top: 1px solid var(--dopus-border);
162
+ }
163
+
164
+ .input {
165
+ flex: 1;
166
+ resize: none;
167
+ border: 1px solid var(--dopus-border);
168
+ border-radius: 8px;
169
+ padding: 8px 10px;
170
+ font: inherit;
171
+ background: var(--dopus-bg);
172
+ color: inherit;
173
+ max-height: 160px;
174
+ }
175
+
176
+ .primary,
177
+ .secondary {
178
+ border-radius: 8px;
179
+ padding: 8px 14px;
180
+ font: inherit;
181
+ cursor: pointer;
182
+ }
183
+
184
+ .primary {
185
+ background: var(--dopus-accent);
186
+ color: var(--dopus-accent-fg);
187
+ border: 1px solid var(--dopus-accent);
188
+ }
189
+
190
+ .primary:disabled {
191
+ opacity: 0.4;
192
+ cursor: default;
193
+ }
194
+
195
+ .secondary {
196
+ background: transparent;
197
+ color: inherit;
198
+ border: 1px solid var(--dopus-border);
199
+ }
200
+
201
+ .link {
202
+ background: none;
203
+ border: 0;
204
+ padding: 0;
205
+ color: inherit;
206
+ text-decoration: underline;
207
+ cursor: pointer;
208
+ font: inherit;
209
+ }
210
+
211
+ .spinner {
212
+ display: inline-block;
213
+ width: 10px;
214
+ height: 10px;
215
+ border: 2px solid currentColor;
216
+ border-right-color: transparent;
217
+ border-radius: 50%;
218
+ animation: dopus-spin 0.8s linear infinite;
219
+ vertical-align: -1px;
220
+ }
221
+
222
+ @keyframes dopus-spin {
223
+ to {
224
+ transform: rotate(360deg);
225
+ }
226
+ }
227
+
228
+ .widget {
229
+ position: fixed;
230
+ right: 20px;
231
+ bottom: 20px;
232
+ z-index: 50;
233
+ display: flex;
234
+ flex-direction: column;
235
+ align-items: flex-end;
236
+ gap: 12px;
237
+ }
238
+
239
+ .panel {
240
+ width: min(400px, calc(100vw - 40px));
241
+ height: min(600px, calc(100vh - 120px));
242
+ box-shadow: 0 12px 40px rgb(0 0 0 / 0.18);
243
+ }
244
+
245
+ .launcher {
246
+ width: 52px;
247
+ height: 52px;
248
+ border-radius: 50%;
249
+ border: 0;
250
+ background: #18181b;
251
+ color: #ffffff;
252
+ font-size: 22px;
253
+ cursor: pointer;
254
+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.2);
255
+ }
@@ -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
+ }