experimental-a2 0.18.1 → 0.20.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 +44 -0
- package/dist/actor.js +1 -1
- 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 +63 -13
- package/dist/ai-fx.js.map +1 -1
- 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-harness-BuLh_EYi.js → ai-harness-Clbg5ei7.js} +88 -15
- package/dist/ai-harness-Clbg5ei7.js.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 +56 -11
- 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 +123 -53
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +1 -1
- package/dist/internal-CscSY7K_.js.map +1 -1
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +5 -5
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +5 -7
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/server-DYAATFuT.d.ts.map +1 -1
- package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
- package/dist/server-qyb5aJJG.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/session-clear-DGI4jTQ5.js.map +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +38 -136
- package/dist/store-postgres.js.map +1 -1
- package/docs/01-quickstart.mdx +7 -4
- package/docs/actors/01-introduction.mdx +1 -1
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/02-tools.mdx +1 -1
- package/docs/agents/03-conversations.mdx +26 -14
- package/docs/agents/04-harnesses.mdx +51 -5
- package/docs/agents/meta.ts +1 -1
- package/docs/client/meta.ts +1 -1
- package/docs/contract/03-sessions.mdx +2 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/01-core.mdx +8 -8
- package/docs/reference/02-server.mdx +3 -3
- package/docs/reference/04-ai.mdx +36 -19
- package/docs/reference/05-telemetry.mdx +132 -0
- package/docs/reference/06-errors.mdx +1 -1
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +112 -0
- package/docs/schedulers/02-vercel-queues.mdx +87 -0
- package/docs/schedulers/03-qstash.mdx +123 -0
- package/docs/schedulers/meta.ts +1 -0
- package/docs/server/01-handlers.mdx +29 -2
- package/docs/server/02-timers.mdx +3 -3
- package/docs/server/06-guarantees.mdx +6 -3
- package/docs/stores/01-overview.mdx +37 -0
- package/docs/stores/02-redis.mdx +49 -0
- package/docs/stores/03-redis-http.mdx +56 -0
- package/docs/stores/04-postgres.mdx +121 -0
- package/docs/stores/05-sqlite.mdx +34 -0
- package/docs/stores/06-memory.mdx +22 -0
- package/docs/stores/meta.ts +1 -0
- package/examples/playground/DEPLOY.md +19 -5
- package/examples/playground/app/a0/a0.css +244 -5
- package/examples/playground/app/a0/app-view.tsx +2 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
- package/examples/playground/app/a0/attachments.ts +133 -0
- package/examples/playground/app/a0/chat.test.tsx +595 -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/shell.tsx +1 -1
- package/examples/playground/app/a0/uploads/route.test.ts +59 -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 +116 -89
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
- package/examples/playground/app/agent/ingress.test.ts +41 -17
- package/examples/playground/app/agent/model-input.test.tsx +92 -43
- 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/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
- package/examples/playground/app/chat/server.test.ts +38 -65
- package/examples/playground/app/components/activity-history.test.tsx +60 -18
- package/examples/playground/app/components/fast-link.test.tsx +3 -5
- package/examples/playground/app/initial-state.test.tsx +86 -45
- package/examples/playground/next.config.ts +7 -4
- package/examples/playground/package.json +4 -1
- package/examples/playground/test/agent.ts +35 -0
- package/examples/playground/test/model.ts +149 -0
- package/examples/playground/test/serve.ts +184 -0
- package/examples/playground/test/server-only.ts +1 -0
- package/examples/playground/vitest.config.ts +27 -0
- package/package.json +3 -1
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +137 -0
- package/src/ai-agent.ts +9 -4
- 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 +95 -23
- package/src/ai-harness.ts +171 -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/errors.ts +1 -1
- package/src/internal.ts +2 -1
- package/src/libfx.d.ts +8 -1
- package/src/postgres-notifications.ts +0 -22
- package/src/postgres-resources.ts +6 -9
- package/src/postgres-schema.ts +80 -0
- package/src/scheduler-qstash.ts +12 -5
- package/src/scheduler-vercel.ts +11 -16
- package/src/server.ts +66 -32
- package/src/store-postgres.ts +5 -123
- 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/dist/server-D-Y4pqwu.js.map +0 -1
- package/docs/reference/05-adapters.mdx +0 -351
- package/docs/server/05-production.mdx +0 -323
- package/examples/playground/app/a0/no-photon.cjs +0 -4
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Postgres
|
|
3
|
+
description: Any Postgres database. You create A2's tables; A2 never changes your schema.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts server/orders.ts
|
|
7
|
+
import { createServer } from 'experimental-a2/server'
|
|
8
|
+
import { postgres } from 'experimental-a2/store-postgres'
|
|
9
|
+
import { orders } from '@/contracts'
|
|
10
|
+
|
|
11
|
+
export const ordersServer = createServer({
|
|
12
|
+
contract: orders,
|
|
13
|
+
store: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
14
|
+
handlers: {
|
|
15
|
+
/* ... */
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```package-install
|
|
21
|
+
npm i pg
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Works with Neon, Supabase, RDS, or your own server, including transaction-mode
|
|
25
|
+
poolers.
|
|
26
|
+
|
|
27
|
+
## Create the tables
|
|
28
|
+
|
|
29
|
+
A2 doesn't create tables in your database. This is the SQL it needs:
|
|
30
|
+
[`schema.sql`](https://github.com/vercel-labs/a2/blob/main/packages/a2/sql/postgres/schema.sql),
|
|
31
|
+
also in the package at `node_modules/experimental-a2/sql/postgres/schema.sql`.
|
|
32
|
+
Apply it once before your first deploy, however you run SQL against your
|
|
33
|
+
database, as the role your app connects with.
|
|
34
|
+
|
|
35
|
+
## Upgrade
|
|
36
|
+
|
|
37
|
+
When a release changes the schema, its changelog says so. Apply that
|
|
38
|
+
release's `schema.sql` before you deploy it. The version you're running keeps
|
|
39
|
+
working against the new schema.
|
|
40
|
+
|
|
41
|
+
If the tables are missing or out of date, the store's first query throws
|
|
42
|
+
`STORE_NOT_CONFIGURED` naming the file to apply. Once it's applied, the next
|
|
43
|
+
request works, with no redeploy. The store checks with one read before its
|
|
44
|
+
first query, which never waits on or blocks writes.
|
|
45
|
+
|
|
46
|
+
## Live updates (polling)
|
|
47
|
+
|
|
48
|
+
By default, live streams poll every 25ms while a session is busy, backing off
|
|
49
|
+
to every two seconds when it's quiet, so an update from another client can take
|
|
50
|
+
up to two seconds, plus query latency, to reach a quiet stream. A client's own
|
|
51
|
+
`push()` refreshes its stream immediately, even when the write and the stream
|
|
52
|
+
run in different invocations.
|
|
53
|
+
|
|
54
|
+
## Live updates (notifications)
|
|
55
|
+
|
|
56
|
+
For instant updates, pass `listenConnectionString`, a direct connection to the
|
|
57
|
+
same database (not through a pooler), and apply
|
|
58
|
+
[`notifications.sql`](https://github.com/vercel-labs/a2/blob/main/packages/a2/sql/postgres/notifications.sql)
|
|
59
|
+
once, the same way as `schema.sql`.
|
|
60
|
+
|
|
61
|
+
Its triggers notify listeners of every write, including writes from stores
|
|
62
|
+
without the option. Notifications arrive after commit, a reconnect catches up
|
|
63
|
+
from the cursor, and subscriptions also reconcile every ten seconds. Every
|
|
64
|
+
serverless instance opens its own direct connection, so leave connection
|
|
65
|
+
headroom.
|
|
66
|
+
|
|
67
|
+
## Use postgres.js
|
|
68
|
+
|
|
69
|
+
`connectionString` creates a `pg` pool. To use
|
|
70
|
+
[postgres.js](https://github.com/porsager/postgres) instead, wrap it as
|
|
71
|
+
`client`:
|
|
72
|
+
|
|
73
|
+
```ts server/store.ts
|
|
74
|
+
import postgresJs from 'postgres'
|
|
75
|
+
import { postgres } from 'experimental-a2/store-postgres'
|
|
76
|
+
|
|
77
|
+
const url = process.env.DATABASE_URL
|
|
78
|
+
if (!url) throw new Error('set DATABASE_URL')
|
|
79
|
+
|
|
80
|
+
const sql = postgresJs(url, {
|
|
81
|
+
// A2 sends JSON as text; this keeps postgres.js from encoding it again.
|
|
82
|
+
types: {
|
|
83
|
+
json: {
|
|
84
|
+
to: 114,
|
|
85
|
+
from: [114, 3802],
|
|
86
|
+
serialize: (x: unknown) => (typeof x === 'string' ? x : JSON.stringify(x)),
|
|
87
|
+
parse: (x: string) => JSON.parse(x),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
export const store = postgres({
|
|
93
|
+
client: {
|
|
94
|
+
query: async (text, values = []) => ({
|
|
95
|
+
rows: [...(await sql.unsafe(text, values))],
|
|
96
|
+
}),
|
|
97
|
+
connect: async () => {
|
|
98
|
+
const connection = await sql.reserve()
|
|
99
|
+
return {
|
|
100
|
+
query: async (text, values = []) => ({
|
|
101
|
+
rows: [...(await connection.unsafe(text, values))],
|
|
102
|
+
}),
|
|
103
|
+
release: () => connection.release(),
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
end: () => sql.end(),
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`listenConnectionString` still opens its listener with `pg`, so live
|
|
112
|
+
notifications need `pg` installed too.
|
|
113
|
+
|
|
114
|
+
## Options
|
|
115
|
+
|
|
116
|
+
| Option | |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| `connectionString` | creates a `pg` pool, shared by stores with the same settings |
|
|
119
|
+
| `client` | your own pool instead: a `pg` `Pool`, PGlite, or a pooler wrapper. Its lifecycle is yours |
|
|
120
|
+
| `listenConnectionString` | a direct connection for notifications |
|
|
121
|
+
| `clock`, `ids` | test seams for timestamps and generated event ids |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: SQLite
|
|
3
|
+
description: The development default, in a file at `.a2/dev.db`.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts server/orders.ts
|
|
7
|
+
import { createServer } from 'experimental-a2/server'
|
|
8
|
+
import { sqlite } from 'experimental-a2/store-sqlite'
|
|
9
|
+
import { orders } from '@/contracts'
|
|
10
|
+
|
|
11
|
+
export const ordersServer = createServer({
|
|
12
|
+
contract: orders,
|
|
13
|
+
store: sqlite({ path: '.a2/orders.db' }),
|
|
14
|
+
handlers: {
|
|
15
|
+
/* ... */
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
In development, a server without a `store` uses SQLite at `.a2/dev.db`, so
|
|
21
|
+
your sessions survive `next dev` restarts. Pass it yourself to pick another
|
|
22
|
+
file.
|
|
23
|
+
|
|
24
|
+
It runs on `node:sqlite`, with no dependencies, and creates its tables when it
|
|
25
|
+
opens the file. It's for development: a serverless filesystem doesn't last or
|
|
26
|
+
share one file across instances, so production needs another
|
|
27
|
+
[store](/stores/overview).
|
|
28
|
+
|
|
29
|
+
## Options
|
|
30
|
+
|
|
31
|
+
| Option | |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `path` | the database file. Default `.a2/dev.db`; `:memory:` works |
|
|
34
|
+
| `clock`, `ids` | test seams for timestamps and generated event ids |
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Memory
|
|
3
|
+
description: An in-process store for tests, the default when `NODE_ENV=test`.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
// in a test:
|
|
8
|
+
import { createServer } from 'experimental-a2/server'
|
|
9
|
+
import { memory } from 'experimental-a2/store-memory'
|
|
10
|
+
import { orders } from '@/contracts'
|
|
11
|
+
|
|
12
|
+
const ordersServer = createServer({ contract: orders, store: memory() })
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Sessions live in the process and vanish when it exits. Under `NODE_ENV=test`, a
|
|
16
|
+
server without a `store` already uses memory.
|
|
17
|
+
|
|
18
|
+
## Options
|
|
19
|
+
|
|
20
|
+
| Option | |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `clock`, `ids` | test seams for timestamps and generated event ids |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { order: 4 }
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The event log runs with zero setup in development (SQLite at `.a2/dev.db`,
|
|
4
4
|
no scheduler). The Agent demo and A0 also need AI Gateway and Vercel Sandbox
|
|
5
|
-
credentials. Production needs
|
|
6
|
-
`node_modules/experimental-a2/docs/
|
|
5
|
+
credentials. Production needs a store
|
|
6
|
+
(`node_modules/experimental-a2/docs/stores/01-overview.mdx`) and a scheduler
|
|
7
|
+
(`node_modules/experimental-a2/docs/schedulers/01-overview.mdx`).
|
|
7
8
|
|
|
8
9
|
## 1. A store
|
|
9
10
|
|
|
@@ -23,7 +24,12 @@ Redis, poll cadence elsewhere. Upstash is durable by default; use its TCP
|
|
|
23
24
|
`rediss://` URL, not the REST one. Each feature creates its own server with
|
|
24
25
|
the shared store; storage remains namespaced per contract. Without either variable, a
|
|
25
26
|
production boot fails fast with `STORE_NOT_CONFIGURED` (by design).
|
|
26
|
-
|
|
27
|
+
Redis needs no setup. With Postgres, create A2's tables once before the
|
|
28
|
+
first deploy (A2 never creates them at runtime):
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f node_modules/experimental-a2/sql/postgres/schema.sql
|
|
32
|
+
```
|
|
27
33
|
|
|
28
34
|
To verify a provider before leaning on it, run the full conformance
|
|
29
35
|
kit against it (throwaway database; the suite isolates by key prefix
|
|
@@ -105,8 +111,8 @@ A sandbox stays up while it's used: each tool call keeps it running at least
|
|
|
105
111
|
10 more minutes, and so does the preview while it's on screen. Otherwise it
|
|
106
112
|
stops, and resumes with its files the next time it's needed. A new or resumed
|
|
107
113
|
sandbox starts `next dev` in the background, so commands don't wait for it;
|
|
108
|
-
the preview does. Deleting an app
|
|
109
|
-
|
|
114
|
+
the preview does. Deleting an app clears its conversation from the log and
|
|
115
|
+
deletes its sandbox. An app nobody uses for 14 days loses its files.
|
|
110
116
|
|
|
111
117
|
App ids are the only access control: anyone with an A0 URL can open that app,
|
|
112
118
|
and anyone who can reach the deployment can start new apps, each with its own
|
|
@@ -116,6 +122,14 @@ of a public deployment. A0's sandboxes are named `a2-a0-*`, which is where to
|
|
|
116
122
|
look when cleaning up. The sidebar lists the apps this browser opened, from a
|
|
117
123
|
cookie.
|
|
118
124
|
|
|
125
|
+
Files attached in A0's chat upload from the browser straight to a public
|
|
126
|
+
Vercel Blob store, through a token from `/a0/uploads`. Connect a Blob store to
|
|
127
|
+
the project, which sets `BLOB_READ_WRITE_TOKEN`; `vercel env pull` brings it
|
|
128
|
+
to local runs. Images go to the model with the message; other files go into
|
|
129
|
+
its text as links, which the agent downloads in its sandbox. Each lives at an
|
|
130
|
+
unguessable public URL under `a0/<app id>/`, which anyone with the URL can
|
|
131
|
+
read. Nothing deletes them, not even deleting their app.
|
|
132
|
+
|
|
119
133
|
A0's dev tools have a Crash button, which ends the function instance running
|
|
120
134
|
an app's agent, mid-turn, so the scheduler resumes the turn in a fresh one.
|
|
121
135
|
Crashing ends everything else that instance is serving, and under `next dev`
|
|
@@ -345,10 +345,140 @@ body:has(.a0-splitter[data-dragging]) iframe {
|
|
|
345
345
|
.a0-text[data-role='user'] {
|
|
346
346
|
align-self: flex-end;
|
|
347
347
|
padding: 0.5rem 0.75rem;
|
|
348
|
+
overflow-wrap: anywhere;
|
|
348
349
|
background: var(--shade);
|
|
349
350
|
color: var(--ink);
|
|
350
351
|
}
|
|
351
352
|
|
|
353
|
+
/* ── attachments ─────────────────────────────────────────────────── */
|
|
354
|
+
|
|
355
|
+
.a0-chat-drop {
|
|
356
|
+
display: contents;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* Over the whole chat column, which positions it. */
|
|
360
|
+
.a0-drop {
|
|
361
|
+
position: absolute;
|
|
362
|
+
z-index: 2;
|
|
363
|
+
inset: 0;
|
|
364
|
+
display: flex;
|
|
365
|
+
align-items: center;
|
|
366
|
+
justify-content: center;
|
|
367
|
+
gap: 0.5rem;
|
|
368
|
+
background: color-mix(in oklab, var(--bg) 85%, transparent);
|
|
369
|
+
outline: 2px dashed var(--muted);
|
|
370
|
+
outline-offset: -0.75rem;
|
|
371
|
+
color: var(--bright);
|
|
372
|
+
pointer-events: none;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.a0-files {
|
|
376
|
+
display: flex;
|
|
377
|
+
flex-wrap: wrap;
|
|
378
|
+
gap: 0.375rem;
|
|
379
|
+
max-width: 85%;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.a0-files[data-role='user'] {
|
|
383
|
+
align-self: flex-end;
|
|
384
|
+
justify-content: flex-end;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.a0-files[data-role='assistant'] {
|
|
388
|
+
align-self: flex-start;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.a0-files img {
|
|
392
|
+
display: block;
|
|
393
|
+
max-width: 12rem;
|
|
394
|
+
max-height: 9rem;
|
|
395
|
+
border: 1px solid var(--line);
|
|
396
|
+
object-fit: cover;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.a0-file {
|
|
400
|
+
display: inline-flex;
|
|
401
|
+
align-items: center;
|
|
402
|
+
gap: 0.25rem;
|
|
403
|
+
color: inherit;
|
|
404
|
+
text-decoration: underline dotted var(--faint);
|
|
405
|
+
text-underline-offset: 3px;
|
|
406
|
+
vertical-align: bottom;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.a0-files .a0-file {
|
|
410
|
+
padding: 0.25rem 0.5rem;
|
|
411
|
+
border: 1px dashed var(--line);
|
|
412
|
+
text-decoration: none;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.a0-attachments {
|
|
416
|
+
display: flex;
|
|
417
|
+
flex-wrap: wrap;
|
|
418
|
+
gap: 0.375rem;
|
|
419
|
+
margin: 0;
|
|
420
|
+
padding: 0.625rem 0.75rem 0;
|
|
421
|
+
list-style: none;
|
|
422
|
+
font-size: 0.75rem;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.a0-attachments li {
|
|
426
|
+
position: relative;
|
|
427
|
+
display: inline-flex;
|
|
428
|
+
align-items: center;
|
|
429
|
+
gap: 0.25rem;
|
|
430
|
+
min-width: 0;
|
|
431
|
+
max-width: 100%;
|
|
432
|
+
color: var(--muted);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.a0-attachments li[data-kind='link'] {
|
|
436
|
+
height: 1.75rem;
|
|
437
|
+
padding-left: 0.5rem;
|
|
438
|
+
border: 1px dashed var(--line);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.a0-attachments li[data-kind='link'] span {
|
|
442
|
+
overflow: hidden;
|
|
443
|
+
text-overflow: ellipsis;
|
|
444
|
+
white-space: nowrap;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.a0-attachments img {
|
|
448
|
+
display: block;
|
|
449
|
+
width: 3rem;
|
|
450
|
+
height: 3rem;
|
|
451
|
+
border: 1px solid var(--line);
|
|
452
|
+
object-fit: cover;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.a0-attachments li[data-kind='image'][data-uploading] img {
|
|
456
|
+
opacity: 0.5;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* On the thumbnail's corners: the spinner, and the button that removes it. */
|
|
460
|
+
.a0-attachments li[data-kind='image'] .a0-spin {
|
|
461
|
+
position: absolute;
|
|
462
|
+
top: calc(50% - 6px);
|
|
463
|
+
left: calc(1.5rem - 6px);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.a0-attachments li[data-kind='image'] .a0-icon-button {
|
|
467
|
+
position: absolute;
|
|
468
|
+
top: -0.375rem;
|
|
469
|
+
right: -0.375rem;
|
|
470
|
+
width: 1.125rem;
|
|
471
|
+
height: 1.125rem;
|
|
472
|
+
border: 1px solid var(--line);
|
|
473
|
+
border-radius: 50%;
|
|
474
|
+
background: var(--bg);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.a0-attachments li[data-kind='link'] .a0-icon-button {
|
|
478
|
+
width: 1.5rem;
|
|
479
|
+
height: 1.5rem;
|
|
480
|
+
}
|
|
481
|
+
|
|
352
482
|
.a0-text[data-role='assistant'] {
|
|
353
483
|
align-self: flex-start;
|
|
354
484
|
color: var(--muted);
|
|
@@ -428,6 +558,86 @@ body:has(.a0-splitter[data-dragging]) iframe {
|
|
|
428
558
|
white-space: nowrap;
|
|
429
559
|
}
|
|
430
560
|
|
|
561
|
+
/* A tool step that shows what the call writes, folded once it's done. */
|
|
562
|
+
.a0-tool {
|
|
563
|
+
align-self: stretch;
|
|
564
|
+
min-width: 0;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.a0-tool summary {
|
|
568
|
+
width: fit-content;
|
|
569
|
+
cursor: pointer;
|
|
570
|
+
list-style: none;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.a0-tool summary::-webkit-details-marker {
|
|
574
|
+
display: none;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.a0-tool summary:hover .a0-step-label {
|
|
578
|
+
color: var(--ink);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.a0-tool[data-failed] summary,
|
|
582
|
+
.a0-tool[data-failed] .a0-step-label {
|
|
583
|
+
color: var(--danger);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
.a0-tool-text {
|
|
587
|
+
max-height: 14rem;
|
|
588
|
+
margin: 0.375rem 0 0 1.125rem;
|
|
589
|
+
padding: 0.5rem 0.625rem;
|
|
590
|
+
overflow: auto;
|
|
591
|
+
background: color-mix(in oklab, var(--shade) 60%, transparent);
|
|
592
|
+
color: var(--muted);
|
|
593
|
+
font-size: 0.6875rem;
|
|
594
|
+
line-height: 1.5;
|
|
595
|
+
overflow-wrap: anywhere;
|
|
596
|
+
scrollbar-color: var(--faint) transparent;
|
|
597
|
+
scrollbar-width: thin;
|
|
598
|
+
white-space: pre-wrap;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/* Messages waiting for the turn to end, just above the composer. */
|
|
602
|
+
.a0-queue {
|
|
603
|
+
display: flex;
|
|
604
|
+
flex-shrink: 0;
|
|
605
|
+
flex-direction: column;
|
|
606
|
+
gap: 0.25rem;
|
|
607
|
+
margin: 0 0.75rem;
|
|
608
|
+
padding: 0;
|
|
609
|
+
list-style: none;
|
|
610
|
+
font-size: 0.75rem;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
.a0-queue li {
|
|
614
|
+
display: flex;
|
|
615
|
+
align-items: center;
|
|
616
|
+
gap: 0.375rem;
|
|
617
|
+
padding: 0.25rem 0.25rem 0.25rem 0.625rem;
|
|
618
|
+
border: 1px dashed var(--line);
|
|
619
|
+
color: var(--muted);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.a0-queue span {
|
|
623
|
+
flex: 1;
|
|
624
|
+
min-width: 0;
|
|
625
|
+
overflow: hidden;
|
|
626
|
+
text-overflow: ellipsis;
|
|
627
|
+
white-space: nowrap;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.a0-queue button {
|
|
631
|
+
height: 1.375rem;
|
|
632
|
+
padding: 0 0.375rem;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
.a0-queue .a0-icon-button {
|
|
636
|
+
width: 1.375rem;
|
|
637
|
+
padding: 0;
|
|
638
|
+
color: var(--faint);
|
|
639
|
+
}
|
|
640
|
+
|
|
431
641
|
.a0-send-error {
|
|
432
642
|
margin: 0;
|
|
433
643
|
padding: 0 1.25rem 0.5rem;
|
|
@@ -533,16 +743,45 @@ body:has(.a0-splitter[data-dragging]) iframe {
|
|
|
533
743
|
gap: 0.5rem;
|
|
534
744
|
}
|
|
535
745
|
|
|
536
|
-
.a0-queued {
|
|
537
|
-
color: var(--faint);
|
|
538
|
-
font-size: 0.75rem;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
746
|
.a0-composer-actions button {
|
|
542
747
|
height: 1.75rem;
|
|
543
748
|
padding: 0 0.625rem;
|
|
544
749
|
}
|
|
545
750
|
|
|
751
|
+
.a0-composer-actions > .a0-icon-button {
|
|
752
|
+
padding: 0;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/* Pause or resume, then stop, as a player's buttons. */
|
|
756
|
+
.a0-player {
|
|
757
|
+
display: flex;
|
|
758
|
+
align-items: center;
|
|
759
|
+
gap: 0.125rem;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
.a0-composer-actions .a0-player button {
|
|
763
|
+
width: 1.75rem;
|
|
764
|
+
padding: 0;
|
|
765
|
+
color: var(--muted);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/* Send, or during a turn Steer or Queue with its mode on the chevron. */
|
|
769
|
+
.a0-send {
|
|
770
|
+
display: flex;
|
|
771
|
+
align-items: center;
|
|
772
|
+
gap: 1px;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
.a0-composer-actions .a0-send-mode {
|
|
776
|
+
width: 1.5rem;
|
|
777
|
+
padding: 0;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
.a0-menu-check {
|
|
781
|
+
margin-right: 0.375rem;
|
|
782
|
+
vertical-align: -0.125rem;
|
|
783
|
+
}
|
|
784
|
+
|
|
546
785
|
.a0-ghost {
|
|
547
786
|
color: var(--muted);
|
|
548
787
|
}
|
|
@@ -44,11 +44,12 @@ export function AppView({
|
|
|
44
44
|
<section aria-label="Chat" className="a0-chat">
|
|
45
45
|
<h1 className="a0-title">{name}</h1>
|
|
46
46
|
<Chat
|
|
47
|
+
appId={appId}
|
|
47
48
|
harness={harness}
|
|
48
49
|
onPrompt={(text) =>
|
|
49
50
|
prompted({
|
|
50
51
|
id: appId,
|
|
51
|
-
name: titleFromPrompt(prompt
|
|
52
|
+
name: titleFromPrompt(prompt || text),
|
|
52
53
|
harness,
|
|
53
54
|
})
|
|
54
55
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { inputs } from 'experimental-a2/ai'
|
|
2
1
|
import { isAppId } from '../../apps'
|
|
3
2
|
import { deleteAppSandbox } from '../../sandbox'
|
|
4
3
|
import { agentServers } from '../../server'
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Deletes the app: its conversation, then its sandbox. A clear reads no
|
|
7
|
+
* state, so it also deletes a log this A2 can no longer fold, and no pending
|
|
8
|
+
* step of the old conversation runs to bring the sandbox back.
|
|
9
9
|
*/
|
|
10
10
|
export async function DELETE(
|
|
11
11
|
_request: Request,
|
|
@@ -14,14 +14,10 @@ export async function DELETE(
|
|
|
14
14
|
const { appId } = await context.params
|
|
15
15
|
if (!isAppId(appId))
|
|
16
16
|
return Response.json({ error: 'Unknown app.' }, { status: 404 })
|
|
17
|
-
//
|
|
17
|
+
// Only one harness's agent has the app's log; clearing the others writes
|
|
18
|
+
// nothing.
|
|
18
19
|
await Promise.all(
|
|
19
|
-
Object.values(agentServers).map(
|
|
20
|
-
const session = server.session(appId)
|
|
21
|
-
const { state } = await session.state()
|
|
22
|
-
if (state.active)
|
|
23
|
-
await session.append(...inputs.stop({ turnId: state.active.turnId }))
|
|
24
|
-
}),
|
|
20
|
+
Object.values(agentServers).map((server) => server.session(appId).clear()),
|
|
25
21
|
)
|
|
26
22
|
await deleteAppSandbox({ appId })
|
|
27
23
|
return Response.json({ ok: true })
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { upload as uploadToBlob } from '@vercel/blob/client'
|
|
3
|
+
import type { FileUIPart } from 'ai'
|
|
4
|
+
import { useEffect, useRef, useState } from 'react'
|
|
5
|
+
import { isAttachmentUrl } from './model'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A file the user attached. An image goes out with the message, for the
|
|
9
|
+
* model to see; any other file goes into the message's text as a link, which
|
|
10
|
+
* the agent downloads in its sandbox.
|
|
11
|
+
*/
|
|
12
|
+
export type Attachment = {
|
|
13
|
+
id: string
|
|
14
|
+
file: File
|
|
15
|
+
kind: 'image' | 'link'
|
|
16
|
+
/** Once Blob has it. */
|
|
17
|
+
part?: FileUIPart
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Attachments = {
|
|
21
|
+
list: Attachment[]
|
|
22
|
+
add: (files: File[]) => void
|
|
23
|
+
/** Stops its upload, if it's still running. */
|
|
24
|
+
remove: (id: string) => void
|
|
25
|
+
clear: () => void
|
|
26
|
+
/** Puts back what a failed send took, before anything attached since. */
|
|
27
|
+
restore: (sent: Attachment[]) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isImage = (file: File): boolean => file.type.startsWith('image/')
|
|
31
|
+
|
|
32
|
+
/** Uploads straight to Blob, with a token from the app's uploads route. */
|
|
33
|
+
async function upload({
|
|
34
|
+
appId,
|
|
35
|
+
file,
|
|
36
|
+
signal,
|
|
37
|
+
}: {
|
|
38
|
+
appId: string
|
|
39
|
+
file: File
|
|
40
|
+
signal: AbortSignal
|
|
41
|
+
}): Promise<FileUIPart> {
|
|
42
|
+
const blob = await uploadToBlob(`a0/${appId}/${file.name}`, file, {
|
|
43
|
+
access: 'public',
|
|
44
|
+
handleUploadUrl: '/a0/uploads',
|
|
45
|
+
clientPayload: appId,
|
|
46
|
+
...(file.type ? { contentType: file.type } : {}),
|
|
47
|
+
abortSignal: signal,
|
|
48
|
+
}).catch((cause: unknown) => {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Couldn't upload ${file.name}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
51
|
+
{ cause },
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
return {
|
|
55
|
+
type: 'file',
|
|
56
|
+
mediaType: blob.contentType,
|
|
57
|
+
filename: file.name,
|
|
58
|
+
url: blob.url,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The line a file that isn't an image adds to the message. */
|
|
63
|
+
const linkLine = (part: FileUIPart): string =>
|
|
64
|
+
`${part.filename ?? 'file'}: ${part.url}`
|
|
65
|
+
|
|
66
|
+
/** One of the composer's link lines, as the file it names. */
|
|
67
|
+
export function attachedLink(
|
|
68
|
+
line: string,
|
|
69
|
+
): { name: string; url: string } | null {
|
|
70
|
+
const match = /^(.+): (https:\/\/\S+)$/.exec(line)
|
|
71
|
+
return match?.[1] && match[2] && isAttachmentUrl(match[2])
|
|
72
|
+
? { name: match[1], url: match[2] }
|
|
73
|
+
: null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The files attached to the next message, each uploading as it's added. */
|
|
77
|
+
export function useAttachments({
|
|
78
|
+
appId,
|
|
79
|
+
onLink,
|
|
80
|
+
onError,
|
|
81
|
+
}: {
|
|
82
|
+
appId: string
|
|
83
|
+
/** A file that isn't an image finished uploading: its line for the text. */
|
|
84
|
+
onLink: (line: string) => void
|
|
85
|
+
onError: (cause: unknown) => void
|
|
86
|
+
}): Attachments {
|
|
87
|
+
const [list, setList] = useState<Attachment[]>([])
|
|
88
|
+
// The uploads still running, which removing their file or leaving the app
|
|
89
|
+
// stops.
|
|
90
|
+
const uploads = useRef(new Map<string, AbortController>())
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const running = uploads.current
|
|
93
|
+
return () => {
|
|
94
|
+
for (const controller of running.values()) controller.abort()
|
|
95
|
+
}
|
|
96
|
+
}, [])
|
|
97
|
+
const drop = (id: string): void =>
|
|
98
|
+
setList((current) => current.filter((item) => item.id !== id))
|
|
99
|
+
const attach = async (file: File): Promise<void> => {
|
|
100
|
+
const id = crypto.randomUUID()
|
|
101
|
+
const kind = isImage(file) ? 'image' : 'link'
|
|
102
|
+
const controller = new AbortController()
|
|
103
|
+
uploads.current.set(id, controller)
|
|
104
|
+
setList((current) => [...current, { id, file, kind }])
|
|
105
|
+
try {
|
|
106
|
+
const part = await upload({ appId, file, signal: controller.signal })
|
|
107
|
+
if (kind === 'link') {
|
|
108
|
+
drop(id)
|
|
109
|
+
onLink(linkLine(part))
|
|
110
|
+
} else
|
|
111
|
+
setList((current) =>
|
|
112
|
+
current.map((item) => (item.id === id ? { ...item, part } : item)),
|
|
113
|
+
)
|
|
114
|
+
} catch (cause) {
|
|
115
|
+
drop(id)
|
|
116
|
+
if (!controller.signal.aborted) onError(cause)
|
|
117
|
+
} finally {
|
|
118
|
+
uploads.current.delete(id)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
list,
|
|
123
|
+
add: (files) => {
|
|
124
|
+
for (const file of files) void attach(file)
|
|
125
|
+
},
|
|
126
|
+
remove: (id) => {
|
|
127
|
+
uploads.current.get(id)?.abort()
|
|
128
|
+
drop(id)
|
|
129
|
+
},
|
|
130
|
+
clear: () => setList([]),
|
|
131
|
+
restore: (sent) => setList((current) => [...sent, ...current]),
|
|
132
|
+
}
|
|
133
|
+
}
|