experimental-a2 0.18.1 → 0.19.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/CHANGELOG.md +28 -0
- package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
- package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +1 -1
- package/dist/ai-control-CvLbW10B.js +46 -0
- package/dist/ai-control-CvLbW10B.js.map +1 -0
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +30 -10
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
- package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
- package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
- package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
- package/dist/ai-mMiWul1T.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.d.ts.map +1 -1
- package/dist/ai-pi.js +55 -10
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +1 -1
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +118 -50
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +1 -1
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +1 -4
- package/dist/scheduler-vercel.js.map +1 -1
- package/docs/agents/03-conversations.mdx +25 -13
- package/docs/agents/04-harnesses.mdx +51 -5
- package/docs/contract/03-sessions.mdx +1 -1
- package/docs/reference/04-ai.mdx +27 -18
- package/docs/reference/05-adapters.mdx +2 -2
- package/docs/server/02-timers.mdx +1 -1
- package/docs/server/05-production.mdx +2 -2
- package/examples/playground/DEPLOY.md +9 -0
- package/examples/playground/app/a0/a0.css +244 -5
- package/examples/playground/app/a0/app-view.tsx +2 -1
- package/examples/playground/app/a0/attachments.ts +133 -0
- package/examples/playground/app/a0/chat.test.tsx +504 -0
- package/examples/playground/app/a0/chat.tsx +623 -63
- package/examples/playground/app/a0/context-menu.tsx +38 -11
- package/examples/playground/app/a0/model.test.ts +28 -0
- package/examples/playground/app/a0/model.ts +30 -0
- package/examples/playground/app/a0/server.ts +5 -0
- package/examples/playground/app/a0/uploads/route.ts +29 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
- package/examples/playground/app/agent/model.test.ts +12 -3
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
- package/examples/playground/next.config.ts +7 -4
- package/examples/playground/package.json +3 -1
- package/package.json +1 -1
- package/src/ai-agent.ts +4 -0
- package/src/ai-client-state.ts +21 -9
- package/src/ai-context-schema.ts +28 -4
- package/src/ai-context.ts +83 -3
- package/src/ai-control-server.ts +70 -43
- package/src/ai-control-state.ts +3 -4
- package/src/ai-control.ts +59 -10
- package/src/ai-coordinator.ts +7 -6
- package/src/ai-fx.ts +58 -20
- package/src/ai-harness.ts +153 -29
- package/src/ai-message-shape.ts +41 -0
- package/src/ai-pi.ts +88 -32
- package/src/ai-stored-state.ts +1 -3
- package/src/ai.ts +116 -77
- package/src/libfx.d.ts +8 -1
- package/src/scheduler-vercel.ts +1 -12
- package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
- package/dist/ai-D6mGlO8j.js.map +0 -1
- package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
- package/dist/ai-harness-BuLh_EYi.js.map +0 -1
- package/examples/playground/app/a0/no-photon.cjs +0 -4
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
import type { ReactNode } from 'react'
|
|
3
|
-
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { DragEvent, ReactNode, SetStateAction } from 'react'
|
|
3
|
+
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
4
4
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Asterisk,
|
|
7
|
+
ChevronDown,
|
|
8
|
+
LoaderCircle,
|
|
9
|
+
Paperclip,
|
|
10
|
+
Pause,
|
|
11
|
+
Play,
|
|
12
|
+
Square,
|
|
13
|
+
X,
|
|
14
|
+
Zap,
|
|
15
|
+
} from 'lucide-react'
|
|
16
|
+
import type { FileUIPart } from 'ai'
|
|
6
17
|
import { inputs } from 'experimental-a2/ai'
|
|
18
|
+
import { type Attachments, attachedLink, useAttachments } from './attachments'
|
|
19
|
+
import { ContextMenu } from './context-menu'
|
|
7
20
|
import { queryString } from './flags'
|
|
8
21
|
import {
|
|
9
22
|
type AppMessage,
|
|
@@ -25,12 +38,17 @@ const suggestions = [
|
|
|
25
38
|
'A tip calculator that splits the bill',
|
|
26
39
|
]
|
|
27
40
|
|
|
28
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* The coding agent's conversation: its words, and one line per tool step.
|
|
43
|
+
* Files dropped anywhere on it attach to the next message.
|
|
44
|
+
*/
|
|
29
45
|
export function Chat({
|
|
46
|
+
appId,
|
|
30
47
|
harness,
|
|
31
48
|
onPrompt,
|
|
32
49
|
autoFocus,
|
|
33
50
|
}: {
|
|
51
|
+
appId: string
|
|
34
52
|
harness: Harness
|
|
35
53
|
onPrompt: (prompt: string) => void
|
|
36
54
|
autoFocus: boolean
|
|
@@ -53,18 +71,69 @@ export function Chat({
|
|
|
53
71
|
const [chosen, setChosen] = useState<ModelChoice | null>(null)
|
|
54
72
|
const settings = chosen ?? state.modelChoice
|
|
55
73
|
|
|
74
|
+
const fail = (cause: unknown): void =>
|
|
75
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
76
|
+
|
|
77
|
+
const [draft, setDraft] = useState('')
|
|
78
|
+
const attachments = useAttachments({
|
|
79
|
+
appId,
|
|
80
|
+
onLink: (line) =>
|
|
81
|
+
setDraft((current) =>
|
|
82
|
+
!current || current.endsWith('\n')
|
|
83
|
+
? `${current}${line}`
|
|
84
|
+
: `${current}\n${line}`,
|
|
85
|
+
),
|
|
86
|
+
onError: fail,
|
|
87
|
+
})
|
|
88
|
+
const [dragging, setDragging] = useState(false)
|
|
89
|
+
|
|
56
90
|
const last = state.messages.at(-1)
|
|
91
|
+
const turn = state.active
|
|
92
|
+
const paused = state.status === 'paused'
|
|
57
93
|
// A send is optimistic, and so is the turn it starts, so this holds from
|
|
58
94
|
// the moment it's pushed rather than once the server accepts it.
|
|
59
95
|
const working =
|
|
60
|
-
state.starting || state.status === 'generating' ||
|
|
96
|
+
state.starting || state.status === 'generating' || turn !== null
|
|
61
97
|
const waiting =
|
|
62
98
|
working &&
|
|
99
|
+
!paused &&
|
|
63
100
|
(last?.role !== 'assistant' || !last.parts.some((part) => isVisible(part)))
|
|
64
101
|
|
|
65
|
-
|
|
102
|
+
// The view follows the conversation's end until the reader scrolls up from
|
|
103
|
+
// it; a send follows it again.
|
|
104
|
+
const view = useRef<HTMLDivElement>(null)
|
|
105
|
+
const list = useRef<HTMLOListElement>(null)
|
|
106
|
+
const following = useRef(true)
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const scroller = view.current
|
|
109
|
+
const transcript = list.current
|
|
110
|
+
if (!scroller || !transcript) return
|
|
111
|
+
const observer = new ResizeObserver(() => {
|
|
112
|
+
if (following.current) toEnd(scroller, transcript)
|
|
113
|
+
})
|
|
114
|
+
observer.observe(scroller)
|
|
115
|
+
observer.observe(transcript)
|
|
116
|
+
return () => observer.disconnect()
|
|
117
|
+
}, [])
|
|
118
|
+
|
|
119
|
+
const send = async ({
|
|
120
|
+
text,
|
|
121
|
+
files = [],
|
|
122
|
+
mode,
|
|
123
|
+
}: {
|
|
124
|
+
text: string
|
|
125
|
+
files?: FileUIPart[]
|
|
126
|
+
mode: SendMode
|
|
127
|
+
}): Promise<void> => {
|
|
66
128
|
setError(null)
|
|
67
|
-
onPrompt(text)
|
|
129
|
+
onPrompt(text || files.map((file) => file.filename ?? 'image').join(', '))
|
|
130
|
+
following.current = true
|
|
131
|
+
if (view.current && list.current) toEnd(view.current, list.current)
|
|
132
|
+
const message: AppMessage = {
|
|
133
|
+
id: crypto.randomUUID(),
|
|
134
|
+
role: 'user',
|
|
135
|
+
parts: [...files, ...(text ? [{ type: 'text' as const, text }] : [])],
|
|
136
|
+
}
|
|
68
137
|
try {
|
|
69
138
|
await push(
|
|
70
139
|
...(chosen === null
|
|
@@ -75,30 +144,61 @@ export function Chat({
|
|
|
75
144
|
payload: { ...chosen, model: chosen.model.trim() },
|
|
76
145
|
},
|
|
77
146
|
]),
|
|
78
|
-
...
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
parts: [{ type: 'text', text }],
|
|
82
|
-
}),
|
|
147
|
+
...(mode === 'steer' && turn
|
|
148
|
+
? inputs.steer({ turnId: turn.turnId, message })
|
|
149
|
+
: inputs.message(message)),
|
|
83
150
|
)
|
|
84
151
|
setChosen(null)
|
|
85
152
|
} catch (cause) {
|
|
86
|
-
|
|
153
|
+
fail(cause)
|
|
87
154
|
throw cause
|
|
88
155
|
}
|
|
89
156
|
}
|
|
90
|
-
const stop = (): void => {
|
|
91
|
-
if (!state.active) return
|
|
92
|
-
push(...inputs.stop({ turnId: state.active.turnId })).catch((cause) =>
|
|
93
|
-
setError(cause instanceof Error ? cause.message : String(cause)),
|
|
94
|
-
)
|
|
95
|
-
}
|
|
96
157
|
|
|
97
158
|
return (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
159
|
+
// Lays nothing out: the chat's column holds its parts, and the overlay
|
|
160
|
+
// covers that column.
|
|
161
|
+
<div
|
|
162
|
+
className="a0-chat-drop"
|
|
163
|
+
onDragEnter={(event) => {
|
|
164
|
+
if (!carriesFiles(event)) return
|
|
165
|
+
event.preventDefault()
|
|
166
|
+
setDragging(true)
|
|
167
|
+
}}
|
|
168
|
+
onDragOver={(event) => {
|
|
169
|
+
if (!carriesFiles(event)) return
|
|
170
|
+
event.preventDefault()
|
|
171
|
+
event.dataTransfer.dropEffect = 'copy'
|
|
172
|
+
}}
|
|
173
|
+
onDragLeave={(event) => {
|
|
174
|
+
// Where the files went: null once they leave the window.
|
|
175
|
+
const to = event.relatedTarget
|
|
176
|
+
if (!(to instanceof Node && event.currentTarget.contains(to)))
|
|
177
|
+
setDragging(false)
|
|
178
|
+
}}
|
|
179
|
+
onDrop={(event) => {
|
|
180
|
+
if (!carriesFiles(event)) return
|
|
181
|
+
event.preventDefault()
|
|
182
|
+
setDragging(false)
|
|
183
|
+
attachments.add(Array.from(event.dataTransfer.files))
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
{dragging && (
|
|
187
|
+
<div className="a0-drop" aria-hidden>
|
|
188
|
+
<Paperclip size={14} aria-hidden />
|
|
189
|
+
Drop to attach
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
{/* Reversed, so it opens at the newest message before any script runs. */}
|
|
193
|
+
<div
|
|
194
|
+
ref={view}
|
|
195
|
+
className="a0-transcript-scroll"
|
|
196
|
+
onScroll={() => {
|
|
197
|
+
if (view.current && list.current)
|
|
198
|
+
following.current = below(view.current, list.current) < FOLLOW_GAP
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
<ol ref={list} className="a0-transcript" aria-label="Conversation">
|
|
102
202
|
{!state.messages.length && (
|
|
103
203
|
<li className="a0-suggestions">
|
|
104
204
|
{suggestions.map((suggestion) => (
|
|
@@ -107,7 +207,11 @@ export function Chat({
|
|
|
107
207
|
type="button"
|
|
108
208
|
className="a0-suggestion"
|
|
109
209
|
disabled={!settings.model.trim()}
|
|
110
|
-
onClick={() =>
|
|
210
|
+
onClick={() =>
|
|
211
|
+
void send({ text: suggestion, mode: 'queue' }).catch(
|
|
212
|
+
() => {},
|
|
213
|
+
)
|
|
214
|
+
}
|
|
111
215
|
>
|
|
112
216
|
<Asterisk size={12} aria-hidden />
|
|
113
217
|
{suggestion}
|
|
@@ -115,21 +219,29 @@ export function Chat({
|
|
|
115
219
|
))}
|
|
116
220
|
</li>
|
|
117
221
|
)}
|
|
118
|
-
{state.messages.flatMap((message) =>
|
|
119
|
-
message.
|
|
222
|
+
{state.messages.flatMap((message) => [
|
|
223
|
+
<MessageFiles key={`${message.id}:files`} message={message} />,
|
|
224
|
+
...message.parts.map((part, index) => (
|
|
120
225
|
<MessagePart
|
|
121
226
|
key={`${message.id}:${index}`}
|
|
122
227
|
message={message}
|
|
123
228
|
part={part}
|
|
229
|
+
paused={paused}
|
|
124
230
|
/>
|
|
125
231
|
)),
|
|
126
|
-
)}
|
|
232
|
+
])}
|
|
127
233
|
{waiting && (
|
|
128
234
|
<li className="a0-step">
|
|
129
235
|
<LoaderCircle size={12} className="a0-spin" aria-hidden />
|
|
130
236
|
Thinking
|
|
131
237
|
</li>
|
|
132
238
|
)}
|
|
239
|
+
{paused && (
|
|
240
|
+
<li className="a0-step">
|
|
241
|
+
<Pause size={12} aria-hidden />
|
|
242
|
+
Paused
|
|
243
|
+
</li>
|
|
244
|
+
)}
|
|
133
245
|
{state.error && (
|
|
134
246
|
<li role="alert" className="a0-error">
|
|
135
247
|
{state.error}
|
|
@@ -142,37 +254,263 @@ export function Chat({
|
|
|
142
254
|
{error}
|
|
143
255
|
</p>
|
|
144
256
|
)}
|
|
257
|
+
{!!state.inbox.items.length && (
|
|
258
|
+
<ol className="a0-queue" aria-label="Queued messages">
|
|
259
|
+
{state.inbox.items.map((item) => (
|
|
260
|
+
<li key={item.id}>
|
|
261
|
+
<span title={summaryOf(item.message)}>
|
|
262
|
+
{summaryOf(item.message)}
|
|
263
|
+
</span>
|
|
264
|
+
{turn && (
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
className="a0-ghost"
|
|
268
|
+
aria-label="Steer with it now"
|
|
269
|
+
title="Steer with it now, at the agent's next step"
|
|
270
|
+
onClick={() =>
|
|
271
|
+
void push(
|
|
272
|
+
...inputs.queue.sendNow({
|
|
273
|
+
inputId: item.id,
|
|
274
|
+
turnId: turn.turnId,
|
|
275
|
+
}),
|
|
276
|
+
).catch(fail)
|
|
277
|
+
}
|
|
278
|
+
>
|
|
279
|
+
Steer
|
|
280
|
+
</button>
|
|
281
|
+
)}
|
|
282
|
+
<button
|
|
283
|
+
type="button"
|
|
284
|
+
className="a0-icon-button"
|
|
285
|
+
aria-label="Remove"
|
|
286
|
+
title="Remove"
|
|
287
|
+
onClick={() =>
|
|
288
|
+
void push(...inputs.queue.remove({ inputId: item.id })).catch(
|
|
289
|
+
fail,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
>
|
|
293
|
+
<X size={12} aria-hidden />
|
|
294
|
+
</button>
|
|
295
|
+
</li>
|
|
296
|
+
))}
|
|
297
|
+
</ol>
|
|
298
|
+
)}
|
|
145
299
|
<Composer
|
|
300
|
+
text={draft}
|
|
301
|
+
onText={setDraft}
|
|
302
|
+
attachments={attachments}
|
|
146
303
|
onSend={send}
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
304
|
+
// A turn can be steered or queued behind, and stopped, once it's
|
|
305
|
+
// accepted and has an id.
|
|
306
|
+
steerable={turn !== null}
|
|
307
|
+
controls={
|
|
308
|
+
(working || state.inbox.paused) && (
|
|
309
|
+
<Player
|
|
310
|
+
paused={state.inbox.paused}
|
|
311
|
+
onPause={() =>
|
|
312
|
+
void push(...inputs.pause({ when: 'now' })).catch(fail)
|
|
313
|
+
}
|
|
314
|
+
onResume={() => void push(...inputs.resume()).catch(fail)}
|
|
315
|
+
// A player's stop also ends its pause, so the next message
|
|
316
|
+
// goes out instead of waiting for Play.
|
|
317
|
+
onStop={
|
|
318
|
+
turn
|
|
319
|
+
? () =>
|
|
320
|
+
void push(
|
|
321
|
+
...inputs.stop({ turnId: turn.turnId }),
|
|
322
|
+
...(state.inbox.paused ? inputs.resume() : []),
|
|
323
|
+
).catch(fail)
|
|
324
|
+
: null
|
|
325
|
+
}
|
|
326
|
+
/>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
151
329
|
settings={settings}
|
|
152
330
|
onSettings={setChosen}
|
|
153
331
|
harness={harness}
|
|
154
332
|
onHarness={state.messages.length ? null : pickHarness}
|
|
155
333
|
autoFocus={autoFocus}
|
|
156
334
|
/>
|
|
157
|
-
|
|
335
|
+
</div>
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const carriesFiles = (event: DragEvent): boolean =>
|
|
340
|
+
event.dataTransfer.types.includes('Files')
|
|
341
|
+
|
|
342
|
+
/** How a message goes out during a turn: into it at its next step, or after it. */
|
|
343
|
+
type SendMode = 'steer' | 'queue'
|
|
344
|
+
|
|
345
|
+
// Closer than this to the end, the view keeps following it.
|
|
346
|
+
const FOLLOW_GAP = 24
|
|
347
|
+
|
|
348
|
+
/** How far the transcript's end is below the bottom of its view. */
|
|
349
|
+
const below = (view: HTMLElement, list: HTMLElement): number =>
|
|
350
|
+
list.getBoundingClientRect().bottom - view.getBoundingClientRect().bottom
|
|
351
|
+
|
|
352
|
+
const toEnd = (view: HTMLElement, list: HTMLElement): void => {
|
|
353
|
+
view.scrollTop += below(view, list)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const textOf = (message: AppMessage): string =>
|
|
357
|
+
message.parts
|
|
358
|
+
.flatMap((part) => (part.type === 'text' ? [part.text] : []))
|
|
359
|
+
.join('\n')
|
|
360
|
+
|
|
361
|
+
const filesOf = (message: AppMessage): FileUIPart[] =>
|
|
362
|
+
message.parts.filter((part): part is FileUIPart => part.type === 'file')
|
|
363
|
+
|
|
364
|
+
/** A message in one line: its text, or the files it sends. */
|
|
365
|
+
const summaryOf = (message: AppMessage): string =>
|
|
366
|
+
textOf(message) ||
|
|
367
|
+
filesOf(message)
|
|
368
|
+
.map((file) => file.filename ?? 'image')
|
|
369
|
+
.join(', ')
|
|
370
|
+
|
|
371
|
+
/** A message's files: images as thumbnails, any other file by its name. */
|
|
372
|
+
function MessageFiles({ message }: { message: AppMessage }): ReactNode {
|
|
373
|
+
const files = filesOf(message)
|
|
374
|
+
if (!files.length) return null
|
|
375
|
+
return (
|
|
376
|
+
<li className="a0-files" data-role={message.role}>
|
|
377
|
+
{files.map((file, index) => (
|
|
378
|
+
<a
|
|
379
|
+
key={index}
|
|
380
|
+
href={file.url}
|
|
381
|
+
target="_blank"
|
|
382
|
+
rel="noreferrer"
|
|
383
|
+
title={file.filename}
|
|
384
|
+
>
|
|
385
|
+
{file.mediaType.startsWith('image/') ? (
|
|
386
|
+
<img src={file.url} alt={file.filename ?? 'Image'} />
|
|
387
|
+
) : (
|
|
388
|
+
<span className="a0-file">
|
|
389
|
+
<Paperclip size={12} aria-hidden />
|
|
390
|
+
{file.filename ?? 'File'}
|
|
391
|
+
</span>
|
|
392
|
+
)}
|
|
393
|
+
</a>
|
|
394
|
+
))}
|
|
395
|
+
</li>
|
|
158
396
|
)
|
|
159
397
|
}
|
|
160
398
|
|
|
399
|
+
/** A user's text, where each link the composer added shows as its file. */
|
|
400
|
+
function Linked({ text }: { text: string }): ReactNode {
|
|
401
|
+
return text.split('\n').map((line, index) => {
|
|
402
|
+
const link = attachedLink(line)
|
|
403
|
+
return (
|
|
404
|
+
<Fragment key={index}>
|
|
405
|
+
{index > 0 && '\n'}
|
|
406
|
+
{link ? (
|
|
407
|
+
<a
|
|
408
|
+
className="a0-file"
|
|
409
|
+
href={link.url}
|
|
410
|
+
target="_blank"
|
|
411
|
+
rel="noreferrer"
|
|
412
|
+
>
|
|
413
|
+
<Paperclip size={12} aria-hidden />
|
|
414
|
+
{link.name}
|
|
415
|
+
</a>
|
|
416
|
+
) : (
|
|
417
|
+
line
|
|
418
|
+
)}
|
|
419
|
+
</Fragment>
|
|
420
|
+
)
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** An image's thumbnail from the file itself, while and after it uploads. */
|
|
425
|
+
function Thumbnail({ file }: { file: File }): ReactNode {
|
|
426
|
+
const [src, setSrc] = useState<string | null>(null)
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
const url = URL.createObjectURL(file)
|
|
429
|
+
setSrc(url)
|
|
430
|
+
return () => URL.revokeObjectURL(url)
|
|
431
|
+
}, [file])
|
|
432
|
+
return src && <img src={src} alt="" />
|
|
433
|
+
}
|
|
434
|
+
|
|
161
435
|
const isVisible = (part: Part): boolean =>
|
|
162
436
|
((part.type === 'text' || part.type === 'reasoning') && !!part.text.trim()) ||
|
|
163
437
|
part.type.startsWith('tool-')
|
|
164
438
|
|
|
439
|
+
/**
|
|
440
|
+
* What a call writes that its one line can't show: a file's text, an edit, or
|
|
441
|
+
* a command over several lines. It streams in as the model writes the call.
|
|
442
|
+
*/
|
|
443
|
+
function written(name: string, input: unknown): string | undefined {
|
|
444
|
+
if (typeof input !== 'object' || input === null) return
|
|
445
|
+
const { content, edits, command } = input as {
|
|
446
|
+
content?: unknown
|
|
447
|
+
edits?: unknown
|
|
448
|
+
command?: unknown
|
|
449
|
+
}
|
|
450
|
+
const text =
|
|
451
|
+
name === 'write' && typeof content === 'string'
|
|
452
|
+
? content
|
|
453
|
+
: name === 'edit'
|
|
454
|
+
? editsOf(input, edits).map(edited).join('\n…\n')
|
|
455
|
+
: name === 'bash' &&
|
|
456
|
+
typeof command === 'string' &&
|
|
457
|
+
command.includes('\n')
|
|
458
|
+
? command
|
|
459
|
+
: undefined
|
|
460
|
+
return text || undefined
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Pi's edits: a list, one edit, or the edit's own fields on the call. */
|
|
464
|
+
const editsOf = (input: object, edits: unknown): unknown[] =>
|
|
465
|
+
Array.isArray(edits)
|
|
466
|
+
? edits
|
|
467
|
+
: typeof edits === 'object' && edits !== null
|
|
468
|
+
? [edits]
|
|
469
|
+
: 'oldText' in input || 'newText' in input
|
|
470
|
+
? [input]
|
|
471
|
+
: []
|
|
472
|
+
|
|
473
|
+
const signed = (text: unknown, sign: string): string[] =>
|
|
474
|
+
typeof text === 'string'
|
|
475
|
+
? text.split('\n').map((line) => `${sign} ${line}`)
|
|
476
|
+
: []
|
|
477
|
+
|
|
478
|
+
/** One of Pi's edits as a diff: the text it replaces, then its replacement. */
|
|
479
|
+
function edited(edit: unknown): string {
|
|
480
|
+
const { oldText, newText } = (edit ?? {}) as {
|
|
481
|
+
oldText?: unknown
|
|
482
|
+
newText?: unknown
|
|
483
|
+
}
|
|
484
|
+
return [...signed(oldText, '-'), ...signed(newText, '+')].join('\n')
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** A call's written text, kept scrolled to its newest line while it streams. */
|
|
488
|
+
function Written({ text, live }: { text: string; live: boolean }): ReactNode {
|
|
489
|
+
const pre = useRef<HTMLPreElement>(null)
|
|
490
|
+
useLayoutEffect(() => {
|
|
491
|
+
if (live && pre.current) pre.current.scrollTop = pre.current.scrollHeight
|
|
492
|
+
}, [text, live])
|
|
493
|
+
return (
|
|
494
|
+
<pre ref={pre} className="a0-tool-text">
|
|
495
|
+
{text}
|
|
496
|
+
</pre>
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
|
|
165
500
|
function MessagePart({
|
|
166
501
|
message,
|
|
167
502
|
part,
|
|
503
|
+
paused,
|
|
168
504
|
}: {
|
|
169
505
|
message: AppMessage
|
|
170
506
|
part: Part
|
|
507
|
+
/** The turn is paused: a call still open waits for Resume. */
|
|
508
|
+
paused: boolean
|
|
171
509
|
}): ReactNode {
|
|
172
510
|
if (part.type === 'text' && part.text.trim())
|
|
173
511
|
return (
|
|
174
512
|
<li className="a0-text" data-role={message.role}>
|
|
175
|
-
{part.text}
|
|
513
|
+
{message.role === 'user' ? <Linked text={part.text} /> : part.text}
|
|
176
514
|
</li>
|
|
177
515
|
)
|
|
178
516
|
if (part.type === 'reasoning' && part.text.trim()) {
|
|
@@ -208,18 +546,41 @@ function MessagePart({
|
|
|
208
546
|
(part.state === 'input-streaming' && writingLabels[name]) ||
|
|
209
547
|
toolLabels[name] ||
|
|
210
548
|
name
|
|
211
|
-
|
|
212
|
-
|
|
549
|
+
const running = !done && !failed && !paused
|
|
550
|
+
const line = (
|
|
551
|
+
<>
|
|
213
552
|
{done || failed ? (
|
|
214
553
|
<Asterisk size={12} className="a0-step-done" aria-hidden />
|
|
215
|
-
) : (
|
|
554
|
+
) : running ? (
|
|
216
555
|
<LoaderCircle size={12} className="a0-spin" aria-hidden />
|
|
556
|
+
) : (
|
|
557
|
+
<Pause size={12} aria-hidden />
|
|
217
558
|
)}
|
|
218
559
|
<span className="a0-step-label">{label}</span>
|
|
219
560
|
{typeof detail === 'string' && <code>{detail}</code>}
|
|
220
561
|
{done && typeof took === 'number' && (
|
|
221
562
|
<span className="a0-step-took">{duration(took)}</span>
|
|
222
563
|
)}
|
|
564
|
+
</>
|
|
565
|
+
)
|
|
566
|
+
const text = written(name, input)
|
|
567
|
+
if (text === undefined)
|
|
568
|
+
return (
|
|
569
|
+
<li
|
|
570
|
+
className="a0-step"
|
|
571
|
+
data-tool={name}
|
|
572
|
+
data-failed={failed || undefined}
|
|
573
|
+
>
|
|
574
|
+
{line}
|
|
575
|
+
</li>
|
|
576
|
+
)
|
|
577
|
+
return (
|
|
578
|
+
<li className="a0-tool" data-tool={name} data-failed={failed || undefined}>
|
|
579
|
+
{/* Open while the call is written and runs, folded once it's done. */}
|
|
580
|
+
<details open={running}>
|
|
581
|
+
<summary className="a0-step">{line}</summary>
|
|
582
|
+
<Written text={text} live={running} />
|
|
583
|
+
</details>
|
|
223
584
|
</li>
|
|
224
585
|
)
|
|
225
586
|
}
|
|
@@ -234,22 +595,146 @@ const reasoningLabel = (level: ModelChoice['reasoning']): string =>
|
|
|
234
595
|
? 'extra high'
|
|
235
596
|
: level
|
|
236
597
|
|
|
237
|
-
/**
|
|
598
|
+
/** A turn's controls, as a player's: pause or resume, and stop. */
|
|
599
|
+
function Player({
|
|
600
|
+
paused,
|
|
601
|
+
onPause,
|
|
602
|
+
onResume,
|
|
603
|
+
onStop,
|
|
604
|
+
}: {
|
|
605
|
+
paused: boolean
|
|
606
|
+
onPause: () => void
|
|
607
|
+
onResume: () => void
|
|
608
|
+
/** Once the turn is accepted and has an id. */
|
|
609
|
+
onStop: (() => void) | null
|
|
610
|
+
}): ReactNode {
|
|
611
|
+
return (
|
|
612
|
+
<div className="a0-player" role="group" aria-label="Turn">
|
|
613
|
+
{paused ? (
|
|
614
|
+
<button
|
|
615
|
+
type="button"
|
|
616
|
+
className="a0-icon-button"
|
|
617
|
+
aria-label="Resume"
|
|
618
|
+
title="Resume"
|
|
619
|
+
onClick={onResume}
|
|
620
|
+
>
|
|
621
|
+
<Play size={13} fill="currentColor" aria-hidden />
|
|
622
|
+
</button>
|
|
623
|
+
) : (
|
|
624
|
+
<button
|
|
625
|
+
type="button"
|
|
626
|
+
className="a0-icon-button"
|
|
627
|
+
aria-label="Pause"
|
|
628
|
+
title="Pause: stop the model and tools now, and keep the turn"
|
|
629
|
+
onClick={onPause}
|
|
630
|
+
>
|
|
631
|
+
<Pause size={13} fill="currentColor" aria-hidden />
|
|
632
|
+
</button>
|
|
633
|
+
)}
|
|
634
|
+
<button
|
|
635
|
+
type="button"
|
|
636
|
+
className="a0-icon-button"
|
|
637
|
+
aria-label="Stop"
|
|
638
|
+
title="Stop: end the turn"
|
|
639
|
+
disabled={!onStop}
|
|
640
|
+
onClick={onStop ?? undefined}
|
|
641
|
+
>
|
|
642
|
+
<Square size={11} fill="currentColor" aria-hidden />
|
|
643
|
+
</button>
|
|
644
|
+
</div>
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const sendTitles: Record<SendMode, string> = {
|
|
649
|
+
steer: 'Steer: the agent reads it at its next step, in this turn',
|
|
650
|
+
queue: 'Queue: it goes out when this turn ends',
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* The send button during a turn: Steer or Queue, with the chevron choosing
|
|
655
|
+
* which. Its menu goes with it when the turn ends.
|
|
656
|
+
*/
|
|
657
|
+
function SteerOrQueue({
|
|
658
|
+
ready,
|
|
659
|
+
mode,
|
|
660
|
+
onMode,
|
|
661
|
+
onClose,
|
|
662
|
+
}: {
|
|
663
|
+
ready: boolean
|
|
664
|
+
mode: SendMode
|
|
665
|
+
onMode: (mode: SendMode) => void
|
|
666
|
+
/** After a pick or Escape, for focus to go back to the message. */
|
|
667
|
+
onClose: () => void
|
|
668
|
+
}): ReactNode {
|
|
669
|
+
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
|
|
670
|
+
const chevron = useRef<HTMLButtonElement>(null)
|
|
671
|
+
return (
|
|
672
|
+
<div className="a0-send">
|
|
673
|
+
<button type="submit" disabled={!ready} title={sendTitles[mode]}>
|
|
674
|
+
{mode === 'steer' ? 'Steer' : 'Queue'}
|
|
675
|
+
</button>
|
|
676
|
+
<button
|
|
677
|
+
ref={chevron}
|
|
678
|
+
type="button"
|
|
679
|
+
className="a0-send-mode"
|
|
680
|
+
aria-label="Send mode"
|
|
681
|
+
aria-haspopup="menu"
|
|
682
|
+
aria-expanded={menu !== null}
|
|
683
|
+
onClick={(event) => {
|
|
684
|
+
const box = event.currentTarget.getBoundingClientRect()
|
|
685
|
+
setMenu((open) => (open ? null : { x: box.right, y: box.top - 4 }))
|
|
686
|
+
}}
|
|
687
|
+
>
|
|
688
|
+
<ChevronDown size={14} aria-hidden />
|
|
689
|
+
</button>
|
|
690
|
+
{menu && (
|
|
691
|
+
<ContextMenu
|
|
692
|
+
at={menu}
|
|
693
|
+
corner="bottom-right"
|
|
694
|
+
trigger={chevron}
|
|
695
|
+
items={(['steer', 'queue'] as const).map((option) => ({
|
|
696
|
+
label: option === 'steer' ? 'Steer' : 'Queue',
|
|
697
|
+
checked: mode === option,
|
|
698
|
+
onSelect: () => onMode(option),
|
|
699
|
+
}))}
|
|
700
|
+
onClose={({ returnFocus }) => {
|
|
701
|
+
setMenu(null)
|
|
702
|
+
if (returnFocus) onClose()
|
|
703
|
+
}}
|
|
704
|
+
/>
|
|
705
|
+
)}
|
|
706
|
+
</div>
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* The message box: a dotted frame with the files attached so far, then the
|
|
712
|
+
* model settings it sends with.
|
|
713
|
+
*/
|
|
238
714
|
function Composer({
|
|
715
|
+
text,
|
|
716
|
+
onText,
|
|
717
|
+
attachments,
|
|
239
718
|
onSend,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
queued,
|
|
719
|
+
steerable,
|
|
720
|
+
controls,
|
|
243
721
|
settings,
|
|
244
722
|
onSettings,
|
|
245
723
|
harness,
|
|
246
724
|
onHarness,
|
|
247
725
|
autoFocus,
|
|
248
726
|
}: {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
727
|
+
text: string
|
|
728
|
+
onText: (text: SetStateAction<string>) => void
|
|
729
|
+
attachments: Attachments
|
|
730
|
+
onSend: (message: {
|
|
731
|
+
text: string
|
|
732
|
+
files: FileUIPart[]
|
|
733
|
+
mode: SendMode
|
|
734
|
+
}) => Promise<void>
|
|
735
|
+
/** A turn is active: a message steers it or queues behind it. */
|
|
736
|
+
steerable: boolean
|
|
737
|
+
controls: ReactNode
|
|
253
738
|
settings: ModelChoice
|
|
254
739
|
onSettings: (settings: ModelChoice) => void
|
|
255
740
|
harness: Harness
|
|
@@ -257,8 +742,9 @@ function Composer({
|
|
|
257
742
|
onHarness: ((harness: Harness) => void) | null
|
|
258
743
|
autoFocus: boolean
|
|
259
744
|
}): ReactNode {
|
|
260
|
-
const [
|
|
745
|
+
const [mode, setMode] = useState<SendMode>('steer')
|
|
261
746
|
const input = useRef<HTMLTextAreaElement>(null)
|
|
747
|
+
const picker = useRef<HTMLInputElement>(null)
|
|
262
748
|
// Typing anywhere else starts a message. Focus moves on keydown, so the
|
|
263
749
|
// key itself lands in the box.
|
|
264
750
|
useEffect(() => {
|
|
@@ -289,7 +775,13 @@ function Composer({
|
|
|
289
775
|
textarea.style.height = 'auto'
|
|
290
776
|
textarea.style.height = `${Math.min(textarea.scrollHeight, 208)}px`
|
|
291
777
|
}, [text])
|
|
292
|
-
|
|
778
|
+
// A file that isn't an image joins the text once it's uploaded, so nothing
|
|
779
|
+
// goes out while one is still uploading.
|
|
780
|
+
const uploading = attachments.list.some((item) => !item.part)
|
|
781
|
+
const ready =
|
|
782
|
+
(!!text.trim() || attachments.list.length > 0) &&
|
|
783
|
+
!uploading &&
|
|
784
|
+
!!settings.model.trim()
|
|
293
785
|
return (
|
|
294
786
|
<form
|
|
295
787
|
className="a0-composer"
|
|
@@ -297,19 +789,69 @@ function Composer({
|
|
|
297
789
|
event.preventDefault()
|
|
298
790
|
const message = text.trim()
|
|
299
791
|
if (!ready) return
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
792
|
+
const sent = attachments.list
|
|
793
|
+
onText('')
|
|
794
|
+
attachments.clear()
|
|
795
|
+
await onSend({
|
|
796
|
+
text: message,
|
|
797
|
+
files: sent.flatMap((item) => (item.part ? [item.part] : [])),
|
|
798
|
+
mode,
|
|
799
|
+
}).catch(() => {
|
|
800
|
+
onText((current) => current || message)
|
|
801
|
+
attachments.restore(sent)
|
|
802
|
+
})
|
|
304
803
|
}}
|
|
305
804
|
>
|
|
805
|
+
{!!attachments.list.length && (
|
|
806
|
+
<ul className="a0-attachments" aria-label="Attachments">
|
|
807
|
+
{attachments.list.map((item) => (
|
|
808
|
+
<li
|
|
809
|
+
key={item.id}
|
|
810
|
+
data-kind={item.kind}
|
|
811
|
+
data-uploading={!item.part || undefined}
|
|
812
|
+
title={item.file.name}
|
|
813
|
+
>
|
|
814
|
+
{item.kind === 'image' ? (
|
|
815
|
+
<Thumbnail file={item.file} />
|
|
816
|
+
) : (
|
|
817
|
+
<>
|
|
818
|
+
<Paperclip size={12} aria-hidden />
|
|
819
|
+
<span>{item.file.name}</span>
|
|
820
|
+
</>
|
|
821
|
+
)}
|
|
822
|
+
{!item.part && (
|
|
823
|
+
<LoaderCircle
|
|
824
|
+
size={12}
|
|
825
|
+
className="a0-spin"
|
|
826
|
+
aria-label="Uploading"
|
|
827
|
+
/>
|
|
828
|
+
)}
|
|
829
|
+
<button
|
|
830
|
+
type="button"
|
|
831
|
+
className="a0-icon-button"
|
|
832
|
+
aria-label={`Remove ${item.file.name}`}
|
|
833
|
+
title="Remove"
|
|
834
|
+
onClick={() => attachments.remove(item.id)}
|
|
835
|
+
>
|
|
836
|
+
<X size={12} aria-hidden />
|
|
837
|
+
</button>
|
|
838
|
+
</li>
|
|
839
|
+
))}
|
|
840
|
+
</ul>
|
|
841
|
+
)}
|
|
306
842
|
<textarea
|
|
307
843
|
ref={input}
|
|
308
844
|
aria-label="Message"
|
|
309
845
|
value={text}
|
|
310
846
|
rows={2}
|
|
311
847
|
autoFocus={autoFocus}
|
|
312
|
-
onChange={(event) =>
|
|
848
|
+
onChange={(event) => onText(event.target.value)}
|
|
849
|
+
onPaste={(event) => {
|
|
850
|
+
const files = Array.from(event.clipboardData.files)
|
|
851
|
+
if (!files.length) return
|
|
852
|
+
event.preventDefault()
|
|
853
|
+
attachments.add(files)
|
|
854
|
+
}}
|
|
313
855
|
onKeyDown={(event) => {
|
|
314
856
|
if (
|
|
315
857
|
event.key === 'Enter' &&
|
|
@@ -389,20 +931,38 @@ function Composer({
|
|
|
389
931
|
</button>
|
|
390
932
|
</div>
|
|
391
933
|
<div className="a0-composer-actions">
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
934
|
+
<input
|
|
935
|
+
ref={picker}
|
|
936
|
+
type="file"
|
|
937
|
+
multiple
|
|
938
|
+
hidden
|
|
939
|
+
onChange={(event) => {
|
|
940
|
+
attachments.add(Array.from(event.target.files ?? []))
|
|
941
|
+
event.target.value = ''
|
|
942
|
+
}}
|
|
943
|
+
/>
|
|
944
|
+
<button
|
|
945
|
+
type="button"
|
|
946
|
+
className="a0-icon-button"
|
|
947
|
+
aria-label="Attach files"
|
|
948
|
+
title="Attach files: images go with the message, other files as links"
|
|
949
|
+
onClick={() => picker.current?.click()}
|
|
950
|
+
>
|
|
951
|
+
<Paperclip size={14} aria-hidden />
|
|
952
|
+
</button>
|
|
953
|
+
{controls}
|
|
954
|
+
{steerable ? (
|
|
955
|
+
<SteerOrQueue
|
|
956
|
+
ready={ready}
|
|
957
|
+
mode={mode}
|
|
958
|
+
onMode={setMode}
|
|
959
|
+
onClose={() => input.current?.focus()}
|
|
960
|
+
/>
|
|
961
|
+
) : (
|
|
962
|
+
<button type="submit" disabled={!ready}>
|
|
963
|
+
Send
|
|
401
964
|
</button>
|
|
402
965
|
)}
|
|
403
|
-
<button type="submit" disabled={!ready}>
|
|
404
|
-
Send
|
|
405
|
-
</button>
|
|
406
966
|
</div>
|
|
407
967
|
</div>
|
|
408
968
|
</form>
|