Compare commits

...
4 changed files with 597 additions and 1 deletions

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
---
title: Add an AI Assistant
description: "Learn how to add an AI chat assistant to your app with Puter.js, with its instructions kept in a serverless worker and each user's chats saved to their own account."
tags: [workers, ai, kv, auth]
order: 63
---
An AI assistant in your app is mostly instructions. A system prompt tells the
model what your app is, what to answer and what to refuse. If that prompt lives
in the browser, anyone can read it, change it, and use your assistant for
something else.
A [serverless worker](/Workers/) keeps the prompt on the server. The browser
only sends the user's message. The worker adds the instructions and the earlier
messages, asks the model, and saves the conversation.
The model call goes through `user.puter`, the Puter account of the user who is
chatting, so each user pays for their own messages under the
[User-Pays model](/user-pays-model/). Their chats are saved in their own
[key-value store](/KV/), not yours.
## Keep the Instructions in the Worker
Put the system prompt and the model settings at the top of the worker file. None
of it is sent to the browser:
```js
// assistant.js
const SYSTEM = `You are the help assistant for Acme Notes, a note-taking app.
Only answer questions about using Acme Notes. Keep answers under 120 words.`;
const MODEL = 'gpt-5.5';
const MAX_MESSAGES = 20; // how much earlier conversation the model sees
const MAX_LENGTH = 2000; // longest message a user may send
function error(status, message) {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
```
To change how the assistant behaves, edit this file. Every user gets the new
instructions on their next message, with no app update.
## Read the Message
The route needs a signed-in user, because the model call is billed to them. Your
app calls it with [`puter.workers.exec()`](/Workers/exec/), which sends the
user's session along as `user`:
```js
router.post('/chat', async ({ request, user }) => {
if (!user) {
return error(401, 'sign in required');
}
let body;
try {
body = await request.json();
} catch {
return error(400, 'body must be JSON');
}
const { message } = body;
if (typeof message !== 'string' || message.trim().length === 0) {
return error(400, 'message is required');
}
if (message.length > MAX_LENGTH) {
return error(400, `message must be at most ${MAX_LENGTH} characters`);
}
const chatId = typeof body.chatId === 'string' ? body.chatId : crypto.randomUUID();
// ...load the chat and ask the model, shown in the next sections
});
```
A request without a `chatId` starts a new chat. See
[Handle errors in a worker](/recipes/handle-worker-errors/) for the `error()`
helper and the checks.
## Load the Earlier Messages
Each chat is one entry in the user's key-value store, under `assistant:` and the
chat's ID. Read it with `user.puter.kv`:
```js
const key = `assistant:${chatId}`;
const chat = (await user.puter.kv.get(key)) ?? { title: message.slice(0, 60), messages: [] };
```
Because it is `user.puter`, the key is in that user's account. One user can
never load another user's chat, even with the right `chatId`.
## Ask the Model
Send the system prompt first, then the last few messages, then the new one, with
[`puter.ai.chat()`](/AI/chat/):
```js
chat.messages.push({ role: 'user', content: message });
let reply;
try {
const response = await user.puter.ai.chat(
[
{ role: 'system', content: SYSTEM },
...chat.messages.slice(-MAX_MESSAGES),
],
{ model: MODEL, normalize: true },
);
reply = response.message.content;
} catch {
return error(502, 'the assistant could not answer right now, try again');
}
```
`normalize: true` makes `message.content` a plain string whichever model you
pick, so changing `MODEL` later doesn't change this code.
Only the last `MAX_MESSAGES` are sent, so a long chat doesn't make every new
message slower and more expensive. The system prompt is always added on top,
which is why it never falls out of the conversation.
## Save the Chat
Add the reply and write the chat back, then return the reply and the chat's ID:
```js
chat.messages.push({ role: 'assistant', content: reply });
chat.updatedAt = Date.now();
await user.puter.kv.set(key, chat);
return { chatId, reply };
```
Put these last three sections together, in order, inside the `/chat` route.
A key-value entry has a size limit, and a chat grows with every message. For
chats that run to hundreds of messages, see
[Split a large value across keys](/recipes/split-a-large-value/).
## Deploy the Worker
Save `assistant.js` to your Puter account and create the worker once, which
gives it an address such as `https://acme-assistant.puter.work`. See
[deployment](/Workers/#deployment) for the steps.
## Chat From Your App
Call the worker with [`puter.workers.exec()`](/Workers/exec/). It takes the same
arguments as `fetch()`, and asks the user to sign in first if they haven't:
```js
const API = 'https://acme-assistant.puter.work';
let chatId = null;
async function send(message) {
const res = await puter.workers.exec(`${API}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId, message }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error);
}
chatId = data.chatId; // keep talking in the same chat
return data.reply;
}
await send('How do I share a note?');
await send('And can I undo that?'); // the model sees the first question too
```
## List Past Chats
When a worker is called from your app, `user.puter.kv` is the same key-value
store your app reads with [`puter.kv`](/KV/). So the app can list the user's
chats directly, without a worker route:
```js
const chats = await puter.kv.list('assistant:', true);
const sidebar = chats
.map(row => ({ chatId: row.key.slice('assistant:'.length), ...row.value }))
.sort((a, b) => b.updatedAt - a.updatedAt);
```
Set `chatId` to one of these to carry on that chat, and delete one with
[`puter.kv.del()`](/KV/del/).
## Notes
- Saved chats belong to the user, and they can edit them, for example to change
what the assistant "said" earlier. That only changes their own chat. The system
prompt is always added by the worker, so they can't remove it.
- To pay for the model yourself instead, call `me.puter.ai.chat()`. Every
message is then billed to you, so add a per-user limit like the one in
[Keep an API key secret](/recipes/keep-api-key-secret/).
@@ -0,0 +1,238 @@
---
title: Build a Public Upload Gallery
description: "Learn how to build a gallery where users post images from your app with Puter.js. Each image is hosted from the user's own account, and a serverless worker keeps the shared list."
tags: [workers, hosting, fs, kv, auth]
order: 59
---
A gallery where users post images needs three things: somewhere to keep each
file, a public link to show it, and one list of every post that all users can
see. Usually that means a storage bucket, a CDN and a database, all paid for by
you.
With Puter, each user keeps their own images. Your app writes the image to the
user's account and [hosts it](/Hosting/) there, so the user pays for its storage
and for the traffic when others view it, under the
[User-Pays model](/user-pays-model/). The only thing your app shares is the list
of links. A [serverless worker](/Workers/) keeps that list in your
[key-value store](/KV/) and checks every link before adding it.
## Host the User's Images
Each user gets one site for their gallery images, created the first time they
post. Save its name with [`puter.kv.set()`](/KV/set/) so the next post reuses
it:
```js
async function getGallerySite() {
let site = await puter.kv.get('gallery:site');
if (!site) {
site = (await puter.hosting.create(puter.randName(), 'gallery')).subdomain;
await puter.kv.set('gallery:site', site);
}
return site;
}
```
`gallery` is a relative path, so it is a folder inside your app's own folder in
the user's account. Nothing else the user has is put online.
[`puter.randName()`](/Utils/randName/) picks a site name that is unlikely to be
taken. Creating a site needs a Puter account with a verified email address.
## Upload an Image
Write the file into the hosted folder with [`puter.fs.write()`](/FS/write/). It
is online as soon as the write finishes:
```js
const TYPES = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
};
async function uploadImage(file) {
const ext = TYPES[file.type];
if (!ext) {
throw new Error('only PNG, JPEG, WebP and GIF images are allowed');
}
const name = `${crypto.randomUUID()}.${ext}`;
await puter.fs.write(`gallery/${name}`, file, { createMissingParents: true });
const site = await getGallerySite();
return `https://${site}.puter.site/${name}`;
}
```
The file is written before `getGallerySite()` runs, because a site can only
point at a folder that exists. `createMissingParents` creates the `gallery`
folder on the first upload.
The file gets a name your app picks, and its extension comes from the `TYPES`
table. The site serves each file by its extension, so an upload can't turn into
an HTML page. SVG is left out for the same reason, since an SVG file can run
scripts.
## Add the Link to the Gallery
The worker owns the shared list. Your app only sends it the link. Since anyone
can call the worker, it doesn't trust the link. It makes two checks before
adding it:
1. **The site belongs to the caller.** [`puter.hosting.get()`](/Hosting/get/)
on `user.puter` rejects for a site the user doesn't own. Because the user
calls the worker from your app, it also rejects sites your app didn't create.
2. **The link is an image that is online.** A `HEAD` request returns the file's
type without downloading it.
```js
// gallery.js
const TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
const HOST_SUFFIX = '.puter.site';
function error(status, message) {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
router.post('/images', async ({ request, user }) => {
if (!user) {
return error(401, 'sign in required');
}
let url;
try {
url = new URL((await request.json()).url);
} catch {
return error(400, 'url is required');
}
const site = url.hostname.slice(0, -HOST_SUFFIX.length);
if (url.protocol !== 'https:' || !url.hostname.endsWith(HOST_SUFFIX) || site.includes('.')) {
return error(400, 'url must be a Puter site');
}
try {
await user.puter.hosting.get(site);
} catch {
return error(403, 'that site is not yours');
}
const res = await fetch(url, { method: 'HEAD' });
if (!res.ok || !TYPES.includes(res.headers.get('Content-Type'))) {
return error(400, 'url must be an image that is online');
}
const { username } = await user.puter.getUser();
const image = { id: crypto.randomUUID(), url: url.href, username, at: Date.now() };
await me.puter.kv.set(`gallery:${image.id}`, image);
return image;
});
```
The entry is written with `me.puter`, which is your own account, so every user
adds to the same list. It holds only the link and who posted it, never the image.
See [Handle errors in a worker](/recipes/handle-worker-errors/) for more on the
`error()` helper.
## List the Images
Anyone can read the gallery, so this route doesn't need a `user`:
```js
router.get('/images', async () => {
const rows = await me.puter.kv.list('gallery:', true);
return rows
.map(row => row.value)
.sort((a, b) => b.at - a.at);
});
```
For a gallery with thousands of images, read one page at a time. See
[Store a large collection](/recipes/store-large-collection/).
## Remove an Image
Users can take their own images out of the gallery, and you can take out any of
them:
```js
const OWNER = 'grace';
router.delete('/images/:id', async ({ params, user }) => {
if (!user) {
return error(401, 'sign in required');
}
const image = await me.puter.kv.get(`gallery:${params.id}`);
if (!image) {
return error(404, 'not found');
}
const { username } = await user.puter.getUser();
if (username !== image.username && username !== OWNER) {
return error(403, 'you can only remove your own images');
}
await me.puter.kv.del(`gallery:${params.id}`);
return { removed: params.id };
});
```
This removes the entry, not the file, which is in the user's account. To delete
the file too, the app calls [`puter.fs.delete()`](/FS/delete/) on its path after
removing the entry, and the link stops working.
## Deploy the Worker
Save `gallery.js` to your Puter account and create the worker once, which gives
it an address such as `https://gallery-api.puter.work`. See
[deployment](/Workers/#deployment) for the steps.
## Put It Together in Your App
Upload the image, then send its link to the worker with
[`puter.workers.exec()`](/Workers/exec/). It works like `fetch()`, sends the
user's session along as `user`, and asks the user to sign in first if they
haven't:
```js
const API = 'https://gallery-api.puter.work';
async function postImage(file) {
const url = await uploadImage(file);
const res = await puter.workers.exec(`${API}/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!res.ok) {
throw new Error((await res.json()).error);
}
return await res.json();
}
async function loadGallery() {
return await (await fetch(`${API}/images`)).json();
// [ { id: '7f3c…', url: 'https://quiet-river-4821.puter.site/1b9e….png',
// username: 'grace', at: 1788827048741 }, … ]
}
```
Reading the gallery doesn't need a session, so a plain `fetch()` is enough there.
Show each image with an `<img>` whose `src` is its `url`.
## Notes
- You pay only for the small list entries. Anyone signed in can still add them,
so limit how often one user can post. See
[Keep an API key secret](/recipes/keep-api-key-secret/) for a per-user limit.
- The image stays under the user's control. If they delete the file or the
site, the link stops working. Hide images that fail to load, or check the
links again from time to time.
+3 -1
View File
@@ -65,7 +65,9 @@ await puter.hosting.update('grace-portfolio', 'my-site-v2');
This is a clean way to release a new version. Build it in a fresh folder while
the old one keeps serving, then switch in one call. To roll back, point the
site at the old folder again.
site at the old folder again. See
[Preview and roll back a site](/recipes/preview-and-roll-back-a-site/) for the
full flow.
## Delete a Site
@@ -0,0 +1,154 @@
---
title: Preview and Roll Back a Site
description: "Learn how to let users preview a new version of their website before it goes live with Puter.js, and roll back to an older version in one click."
tags: [hosting, fs, kv]
order: 57
---
When your app [publishes websites](/recipes/add-website-publishing/) for its
users, every edit goes live the moment it is published. A typo, a broken link or
a missing image is in front of every visitor until the user fixes it.
A safer flow has three steps. Each version is saved to its own folder. The user
first sees it on a separate preview address. Then they make it live, and they
can go back to an older version if something is wrong. The
[hosting API](/Hosting/) makes each step one call, because a site is just a name
that points at a folder.
The examples below use two sites per project: `grace-portfolio` is the live
site, and `grace-portfolio-preview` is the preview.
## Save Each Version to Its Own Folder
Never write over the folder the live site serves. Write each version to a new
folder instead, numbered with [`puter.kv.incr()`](/KV/incr/):
```js
async function saveVersion(projectId, files) {
const n = await puter.kv.incr(`site:${projectId}:lastVersion`);
const dir = `site-versions/${projectId}/v${n}`;
for (const [path, content] of Object.entries(files)) {
await puter.fs.write(`${dir}/${path}`, content, { createMissingParents: true });
}
return { n, dir };
}
const version = await saveVersion('portfolio', {
'index.html': '<h1>Grace Hopper</h1><img src="images/me.jpg">',
'images/me.jpg': photoBlob,
});
// { n: 4, dir: 'site-versions/portfolio/v4' }
```
[`puter.kv.incr()`](/KV/incr/) adds 1 on the server and returns the new
number, so two saves at the same time never get the same folder.
`createMissingParents` creates `v4` and `v4/images` as part of the write.
## Show a Preview
Point the preview site at the new folder. The first time, there is no preview
site yet, so create it with [`puter.hosting.create()`](/Hosting/create/). After
that, move it with [`puter.hosting.update()`](/Hosting/update/):
```js
async function pointSite(name, dir) {
try {
return await puter.hosting.update(name, dir);
} catch {
return await puter.hosting.create(name, dir);
}
}
await pointSite('grace-portfolio-preview', version.dir);
// https://grace-portfolio-preview.puter.site now shows version 4
```
`update()` rejects when the site does not exist yet, which is what sends the
first call to `create()`. Show the user a link to the preview address so they
can click through it before anything changes for their visitors.
## Make It Live
When the user is happy, point the live site at the same folder. Nothing is
uploaded again, because the files are already in place:
```js
async function publish(projectId, version) {
await pointSite('grace-portfolio', version.dir);
const history = (await puter.kv.get(`site:${projectId}:history`)) ?? [];
history.push({ n: version.n, dir: version.dir, at: Date.now() });
await puter.kv.set(`site:${projectId}:history`, history);
}
await publish('portfolio', version);
```
The live address stays the same. Visitors get the new version on their next page
load, and nobody ever sees a half-written folder.
The history is a small list of every version that has been live, kept in one
[key-value](/KV/) entry. See [Store a small list](/recipes/store-small-list/)
for when one entry is enough.
## Roll Back
To undo a release, point the live site at the version before it:
```js
async function rollBack(projectId) {
const history = (await puter.kv.get(`site:${projectId}:history`)) ?? [];
if (history.length < 2) {
throw new Error('there is no older version to go back to');
}
history.pop();
const previous = history[history.length - 1];
await puter.hosting.update('grace-portfolio', previous.dir);
await puter.kv.set(`site:${projectId}:history`, history);
return previous;
}
```
The bad version's folder is still there. Only the site stops pointing at it, so
the user can preview it again, fix it and publish once more. To jump to any
older version instead of just the last one, call
[`puter.hosting.update()`](/Hosting/update/) with that version's `dir`.
## Delete Old Versions
Every version is a full copy of the site, so delete old ones from time to time.
Keep the last few that have been live, and never delete the one the live or
preview site points at:
```js
async function deleteOldVersions(projectId, keep = 5) {
const history = (await puter.kv.get(`site:${projectId}:history`)) ?? [];
const lastVersion = await puter.kv.get(`site:${projectId}:lastVersion`);
const kept = new Set(history.slice(-keep).map(v => v.n));
kept.add(lastVersion); // the version the preview shows, which may not be live yet
for (let n = 1; n <= lastVersion; n++) {
if (!kept.has(n)) {
await puter.fs.delete(`site-versions/${projectId}/v${n}`).catch(() => {});
}
}
await puter.kv.set(`site:${projectId}:history`, history.filter(v => kept.has(v.n)));
}
```
[`puter.fs.delete()`](/FS/delete/) removes a folder with everything in it. The
`catch` skips folders that were already deleted on an earlier run.
## Notes
- Both sites belong to the user and count toward their sites. When your code
runs as a Puter app, it can only change sites that app created. See
[Manage websites](/recipes/manage-websites/).
- The preview address is public, just harder to guess. Don't put anything on it
that must stay private.
- To take the preview offline after publishing, call
[`puter.hosting.delete()`](/Hosting/delete/) with its name. The version's
folder is kept.