feat: restore a bot from its export (#128)
Bot settings could export a bot as JSON, but nothing could read that file back, so an export was not a way to recover or move a bot. bots.import creates a new bot from an export manifest: profile and instructions, bot memory, home files and routines. Team Computer paths are rewritten to the new bot's workspace, unsafe paths reject the whole import, imported routines start paused and one-shot schedules are left out. History is not replayed; the bot starts its own conversation. A failed import removes the half-created bot. The New bot panel gets an import button that takes the exported file. Claude-Session: https://claude.ai/code/session_01Sbaz56zNFZBJ1whrpasMMs Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6135bd8d63
commit
77559a6284
74
apps/api/src/bot-import.test.ts
Normal file
74
apps/api/src/bot-import.test.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
|
||||
|
||||
describe("safeImportPath", () => {
|
||||
it("normalizes separators and dot segments", () => {
|
||||
expect(safeImportPath("notes\\./result.txt")).toBe("notes/result.txt");
|
||||
expect(safeImportPath("MEMORY.md")).toBe("MEMORY.md");
|
||||
});
|
||||
|
||||
it("rejects paths that could leave the bot home", () => {
|
||||
expect(safeImportPath("../secrets")).toBeNull();
|
||||
expect(safeImportPath("notes/../../x")).toBeNull();
|
||||
expect(safeImportPath("/etc/passwd")).toBeNull();
|
||||
expect(safeImportPath("C:\\Windows")).toBeNull();
|
||||
expect(safeImportPath("./")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("relativeImportedFiles", () => {
|
||||
it("drops the old Team Computer workspace prefix", () => {
|
||||
expect(
|
||||
relativeImportedFiles([
|
||||
{ path: "bots/old-id/notes/result.txt", content: "a" },
|
||||
{ path: "bots/old-id/README.md", content: "b" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ path: "notes/result.txt", content: "a" },
|
||||
{ path: "README.md", content: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps Private Computer paths as they are", () => {
|
||||
const files = [
|
||||
{ path: "notes/result.txt", content: "a" },
|
||||
{ path: "bots/other/file.txt", content: "b" },
|
||||
];
|
||||
expect(relativeImportedFiles(files)).toEqual(files);
|
||||
});
|
||||
|
||||
it("keeps paths when files span several workspaces", () => {
|
||||
const files = [
|
||||
{ path: "bots/a/x.txt", content: "a" },
|
||||
{ path: "bots/b/y.txt", content: "b" },
|
||||
];
|
||||
expect(relativeImportedFiles(files)).toEqual(files);
|
||||
});
|
||||
|
||||
it("refuses the whole set when one path is unsafe", () => {
|
||||
expect(
|
||||
relativeImportedFiles([
|
||||
{ path: "ok.txt", content: "a" },
|
||||
{ path: "../escape.txt", content: "b" },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts an export without files", () => {
|
||||
expect(relativeImportedFiles([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("importableRoutines", () => {
|
||||
it("leaves out one-shot schedules", () => {
|
||||
const routines = [
|
||||
{ name: "daily", crons: ["0 9 * * *"] },
|
||||
{ name: "once", crons: ["@once"] },
|
||||
{ name: "webhook", crons: [] },
|
||||
];
|
||||
expect(importableRoutines(routines).map((routine) => routine.name)).toEqual([
|
||||
"daily",
|
||||
"webhook",
|
||||
]);
|
||||
});
|
||||
});
|
||||
34
apps/api/src/bot-import.ts
Normal file
34
apps/api/src/bot-import.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { isOneShotRoutineCrons } from "@rakazo/core";
|
||||
|
||||
/** A relative, forward-slash path without `.`/`..` segments, or null when it could escape. */
|
||||
export function safeImportPath(raw: string): string | null {
|
||||
const normalized = raw.replace(/\\/g, "/");
|
||||
if (normalized.startsWith("/") || /^[a-z]:/i.test(normalized)) return null;
|
||||
const segments = normalized.split("/").filter((segment) => segment !== "" && segment !== ".");
|
||||
if (segments.length === 0 || segments.includes("..")) return null;
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports of bots on a Team Computer carry their workspace prefix (`bots/<old id>/…`).
|
||||
* The prefix names the old bot, so it is dropped and the importer writes under the new one.
|
||||
*/
|
||||
export function relativeImportedFiles<T extends { path: string }>(
|
||||
files: T[],
|
||||
): Array<T & { path: string }> | null {
|
||||
const safe: Array<T & { path: string }> = [];
|
||||
for (const file of files) {
|
||||
const path = safeImportPath(file.path);
|
||||
if (!path) return null;
|
||||
safe.push({ ...file, path });
|
||||
}
|
||||
const first = safe[0]?.path.split("/");
|
||||
const workspace = first && first.length > 2 && first[0] === "bots" ? `bots/${first[1]}/` : null;
|
||||
if (!workspace || !safe.every((file) => file.path.startsWith(workspace))) return safe;
|
||||
return safe.map((file) => ({ ...file, path: file.path.slice(workspace.length) }));
|
||||
}
|
||||
|
||||
/** One-shot schedules belong to the chat that set them, so they are not carried over. */
|
||||
export function importableRoutines<T extends { crons: string[] }>(routines: T[]): T[] {
|
||||
return routines.filter((routine) => !isOneShotRoutineCrons(routine.crons));
|
||||
}
|
||||
@ -168,6 +168,7 @@ import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-sec
|
||||
import { createAgentSkillsService } from "./agent-skills.js";
|
||||
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
||||
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
|
||||
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
|
||||
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
|
||||
import { createCoachService } from "./coach.js";
|
||||
import {
|
||||
@ -1253,6 +1254,87 @@ export function createRouter(deps: RouterDeps) {
|
||||
}
|
||||
return duplicate;
|
||||
}),
|
||||
import: authed.bots.import.handler(async ({ context, input }) => {
|
||||
const files = relativeImportedFiles(input.files);
|
||||
const memory = input.memory.flatMap((doc) => {
|
||||
const path = safeImportPath(doc.path);
|
||||
return path ? [{ path, content: doc.content }] : [];
|
||||
});
|
||||
if (!files || memory.length !== input.memory.length) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "This export has an unsafe file path." });
|
||||
}
|
||||
const routines = importableRoutines(input.routines);
|
||||
for (const routine of routines) {
|
||||
if (hasMixedOneShotSchedule(routine.crons)) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "A one-time schedule can't be combined with other schedules.",
|
||||
});
|
||||
}
|
||||
if (routine.crons.length > 0) nextRoutineDate(routine.crons, routine.timezone);
|
||||
}
|
||||
const created = await repos
|
||||
.createBot(context.actor, { ...input.bot, notifyOnFinish: true })
|
||||
.catch((error: unknown) => {
|
||||
throw mapSpaceLifecycleError(error);
|
||||
});
|
||||
const bot = await repos.getBot(context.actor, created.id);
|
||||
const importContext = {
|
||||
operationId: "import",
|
||||
traceId: "import",
|
||||
spaceId: context.actor.spaceId,
|
||||
userId: context.actor.userId,
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
try {
|
||||
if (!bot.computer) throw new IsolationError();
|
||||
for (const doc of memory) {
|
||||
await deps.memory.commit(
|
||||
{ scope: "bot", botId: bot.id, path: doc.path, content: doc.content },
|
||||
importContext,
|
||||
);
|
||||
}
|
||||
const prefix = bot.computer.scope === "team" ? teamBotWorkspaceDirectory(bot.id) : "";
|
||||
for (const file of files) {
|
||||
await deps.home.writeFile(
|
||||
bot.computer.homeKey,
|
||||
prefix ? `${prefix}/${file.path}` : file.path,
|
||||
file.content,
|
||||
importContext,
|
||||
);
|
||||
}
|
||||
// Imported schedules start paused: the user decides when this bot may act on its own.
|
||||
if (routines.length) {
|
||||
await deps.prisma.routine.createMany({
|
||||
data: routines.map((routine) => ({
|
||||
spaceId: context.actor.spaceId,
|
||||
botId: bot.id,
|
||||
userId: context.actor.userId,
|
||||
name: routine.name,
|
||||
prompt: routine.prompt,
|
||||
crons: routine.crons,
|
||||
timezone: routine.timezone,
|
||||
active: false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await destroyBot(
|
||||
{
|
||||
prisma: deps.prisma,
|
||||
sandbox: deps.sandbox,
|
||||
home: deps.home,
|
||||
jobs: deps.jobs,
|
||||
artifacts: deps.artifacts,
|
||||
dataDir: deps.dataDir,
|
||||
},
|
||||
bot,
|
||||
{ ...importContext, operationId: "destroy", traceId: "destroy" },
|
||||
{ deleteMemories: true },
|
||||
).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return created;
|
||||
}),
|
||||
reorder: authed.bots.reorder.handler(async ({ context, input }) => {
|
||||
await repos.reorderBots(context.actor, input.botIds);
|
||||
return { ok: true as const };
|
||||
|
||||
@ -179,3 +179,66 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "31-deleted-bot-fallback");
|
||||
});
|
||||
|
||||
test("a bot export restores as a new bot", async ({ page }, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `bot-import-${stamp}@rakazo.test`, "password12", "Bot Import");
|
||||
await completeOnboarding(page);
|
||||
await page.goto("/app");
|
||||
await page.waitForURL(/\/app\/[^/]+$/);
|
||||
|
||||
await openNewBot(page);
|
||||
const form = page.getByTestId("create-bot-form");
|
||||
await expect(form.getByRole("button", { name: "Import bot" })).toBeVisible();
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", colorScheme);
|
||||
await captureScreenshot(page, testInfo, `27d-create-bot-import-${colorScheme}`);
|
||||
}
|
||||
|
||||
const fileInput = form.getByTestId("import-bot-file");
|
||||
await fileInput.setInputFiles({
|
||||
name: "broken.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from("{"),
|
||||
});
|
||||
await expect(page.getByTestId("create-bot-error")).toHaveText("Could not import this file");
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: "archivist-export.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
bot: {
|
||||
name: "Archivist",
|
||||
title: "Keeps the records",
|
||||
description: "Files and finds documents.",
|
||||
instructions: "Name every file by date.",
|
||||
},
|
||||
memory: [{ path: "MEMORY.md", content: "# Archive\n\nPrefers ISO dates." }],
|
||||
routines: [
|
||||
{
|
||||
name: "Weekly sweep",
|
||||
prompt: "Tidy the archive",
|
||||
crons: ["0 8 * * 1"],
|
||||
timezone: "UTC",
|
||||
},
|
||||
],
|
||||
files: [{ path: "notes/index.md", content: "imported" }],
|
||||
history: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
|
||||
const botList = page.locator("aside").first();
|
||||
await expect(botList.getByRole("button", { name: /^Archivist/ })).toBeVisible();
|
||||
await page.locator("main").getByRole("button", { name: "Archivist", exact: true }).click();
|
||||
await expect(page.locator("label:has-text('Title') input")).toHaveValue("Keeps the records");
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", colorScheme);
|
||||
await captureScreenshot(page, testInfo, `27e-imported-bot-${colorScheme}`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1511,6 +1511,10 @@ msgstr "{name} konnte nicht heruntergeladen werden. Versuche es erneut."
|
||||
msgid "Could not enable speaking"
|
||||
msgstr "Vorlesen konnte nicht aktiviert werden"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Could not import this file"
|
||||
msgstr "Diese Datei konnte nicht importiert werden"
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Could not install connector"
|
||||
msgstr "Connector konnte nicht installiert werden"
|
||||
@ -2538,6 +2542,10 @@ msgstr "Ignorieren"
|
||||
msgid "Image upload area"
|
||||
msgstr "Bild-Upload-Bereich"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Import bot"
|
||||
msgstr "Bot importieren"
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Import OpenAPI JSON"
|
||||
msgstr "OpenAPI-JSON importieren"
|
||||
|
||||
@ -1511,6 +1511,10 @@ msgstr "Could not download {name}. Try again."
|
||||
msgid "Could not enable speaking"
|
||||
msgstr "Could not enable speaking"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Could not import this file"
|
||||
msgstr "Could not import this file"
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Could not install connector"
|
||||
msgstr "Could not install connector"
|
||||
@ -2538,6 +2542,10 @@ msgstr "Ignore"
|
||||
msgid "Image upload area"
|
||||
msgstr "Image upload area"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Import bot"
|
||||
msgstr "Import bot"
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Import OpenAPI JSON"
|
||||
msgstr "Import OpenAPI JSON"
|
||||
|
||||
@ -1511,6 +1511,10 @@ msgstr "No se pudo descargar {name}. Inténtalo de nuevo."
|
||||
msgid "Could not enable speaking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Could not import this file"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Could not install connector"
|
||||
msgstr "No se pudo instalar el conector"
|
||||
@ -2538,6 +2542,10 @@ msgstr ""
|
||||
msgid "Image upload area"
|
||||
msgstr "Área de carga de imagen"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Import bot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Import OpenAPI JSON"
|
||||
msgstr "Importar JSON de OpenAPI"
|
||||
|
||||
@ -1511,6 +1511,10 @@ msgstr "{name} डाउनलोड नहीं हो सका। पुन
|
||||
msgid "Could not enable speaking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Could not import this file"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Could not install connector"
|
||||
msgstr "कनेक्टर इंस्टॉल नहीं हो सका"
|
||||
@ -2538,6 +2542,10 @@ msgstr ""
|
||||
msgid "Image upload area"
|
||||
msgstr "छवि अपलोड क्षेत्र"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Import bot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Import OpenAPI JSON"
|
||||
msgstr "OpenAPI JSON इम्पोर्ट करें"
|
||||
|
||||
@ -1511,6 +1511,10 @@ msgstr "{name}을 다운로드하지 못했습니다. 다시 시도해 주세요
|
||||
msgid "Could not enable speaking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Could not import this file"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Could not install connector"
|
||||
msgstr "도구 연결을 추가하지 못했습니다."
|
||||
@ -2538,6 +2542,10 @@ msgstr ""
|
||||
msgid "Image upload area"
|
||||
msgstr "이미지 업로드 영역"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Import bot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Import OpenAPI JSON"
|
||||
msgstr "OpenAPI JSON 가져오기"
|
||||
|
||||
@ -1511,6 +1511,10 @@ msgstr "Não foi possível baixar o {name}. Tente novamente."
|
||||
msgid "Could not enable speaking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Could not import this file"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Could not install connector"
|
||||
msgstr "Não foi possível instalar o conector"
|
||||
@ -2538,6 +2542,10 @@ msgstr ""
|
||||
msgid "Image upload area"
|
||||
msgstr "Área de envio de imagem"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Import bot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Import OpenAPI JSON"
|
||||
msgstr "Importar OpenAPI JSON"
|
||||
|
||||
@ -1511,6 +1511,10 @@ msgstr "Не удалось загрузить {name}. Попробуйте ещ
|
||||
msgid "Could not enable speaking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Could not import this file"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Could not install connector"
|
||||
msgstr "Не удалось установить коннектор"
|
||||
@ -2538,6 +2542,10 @@ msgstr "игнорировать"
|
||||
msgid "Image upload area"
|
||||
msgstr "Область загрузки изображения"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Import bot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Import OpenAPI JSON"
|
||||
msgstr "Импортировать OpenAPI JSON"
|
||||
|
||||
@ -1511,6 +1511,10 @@ msgstr "{name} indirilemedi. Tekrar dene."
|
||||
msgid "Could not enable speaking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Could not import this file"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Could not install connector"
|
||||
msgstr "Bağlayıcı kurulamadı"
|
||||
@ -2538,6 +2542,10 @@ msgstr ""
|
||||
msgid "Image upload area"
|
||||
msgstr "Görüntü yükleme alanı"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Import bot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Import OpenAPI JSON"
|
||||
msgstr "OpenAPI JSON içe aktar"
|
||||
|
||||
@ -1511,6 +1511,10 @@ msgstr "无法下载 {name}。请重试。"
|
||||
msgid "Could not enable speaking"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Could not import this file"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Could not install connector"
|
||||
msgstr "无法安装连接器"
|
||||
@ -2538,6 +2542,10 @@ msgstr ""
|
||||
msgid "Image upload area"
|
||||
msgstr "图片上传区域"
|
||||
|
||||
#: src/pages/shell/bot-panel.tsx
|
||||
msgid "Import bot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/PluginsOverlay.tsx
|
||||
msgid "Import OpenAPI JSON"
|
||||
msgstr "导入 OpenAPI JSON"
|
||||
|
||||
@ -27,6 +27,7 @@ import type {
|
||||
Connection,
|
||||
ConnectionCatalogItem,
|
||||
Group,
|
||||
ImportBotInput,
|
||||
MainConversationRef,
|
||||
Me,
|
||||
ProductEvent,
|
||||
@ -3104,6 +3105,16 @@ export function ShellPage() {
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
|
||||
async function importBot(manifest: unknown) {
|
||||
const bot = await rpc.bots.import(manifest as ImportBotInput);
|
||||
setBots((current) =>
|
||||
current.some((item) => item.id === bot.id) ? current : [bot, ...current],
|
||||
);
|
||||
navigate(`/app/${bot.id}`);
|
||||
setPanel(null);
|
||||
await refreshBots().catch(() => undefined);
|
||||
}
|
||||
|
||||
async function createBot(input: {
|
||||
name: string;
|
||||
title: string;
|
||||
@ -4959,6 +4970,7 @@ export function ShellPage() {
|
||||
<CreateBotForm
|
||||
onCancel={() => setPanel(null)}
|
||||
onCreate={(input) => createBot(input)}
|
||||
onImport={(manifest) => importBot(manifest)}
|
||||
/>
|
||||
) : null}
|
||||
{/* Centered Create Bot Modal */}
|
||||
@ -4990,6 +5002,10 @@ export function ShellPage() {
|
||||
onCreate={async (input) => {
|
||||
await createBot(input);
|
||||
}}
|
||||
onImport={async (manifest) => {
|
||||
await importBot(manifest);
|
||||
setShowCreateBotModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@ -30,7 +30,7 @@ import {
|
||||
Textarea,
|
||||
Toggle,
|
||||
} from "@rakazo/ui-web";
|
||||
import { X } from "lucide-react";
|
||||
import { Upload, X } from "lucide-react";
|
||||
import { Suspense, useEffect, useId, useRef, useState } from "react";
|
||||
import { connectedModelOptions } from "../../lib/connected-models";
|
||||
import { lazyWithRecovery } from "../../lib/lazy-recovery";
|
||||
@ -87,6 +87,7 @@ function ComputerModePicker({
|
||||
|
||||
export function CreateBotForm({
|
||||
onCreate,
|
||||
onImport,
|
||||
onCancel,
|
||||
}: {
|
||||
onCreate: (input: {
|
||||
@ -95,10 +96,13 @@ export function CreateBotForm({
|
||||
description: string;
|
||||
computerMode: ComputerMode;
|
||||
}) => Promise<void>;
|
||||
/** Restores a bot from the JSON that the bot settings' Export downloads. */
|
||||
onImport: (manifest: unknown) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const ids = useId();
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
@ -124,15 +128,51 @@ export function CreateBotForm({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(file: File) {
|
||||
if (submitting) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onImport(JSON.parse(await file.text()));
|
||||
} catch {
|
||||
setError(t`Could not import this file`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="create-bot-form">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-ui-body text-muted-foreground">
|
||||
<Trans>New bot</Trans>
|
||||
</span>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={t`Cancel new bot`} onClick={onCancel}>
|
||||
<X strokeWidth={1.8} className="size-icon-md" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t`Import bot`}
|
||||
disabled={submitting}
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
>
|
||||
<Upload strokeWidth={1.8} className="size-icon-md" />
|
||||
</Button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
data-testid="import-bot-file"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) void handleImport(file);
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={t`Cancel new bot`} onClick={onCancel}>
|
||||
<X strokeWidth={1.8} className="size-icon-md" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p
|
||||
|
||||
@ -1308,6 +1308,30 @@ export const ExportManifestSchema = z.object({
|
||||
});
|
||||
export type ExportManifest = z.infer<typeof ExportManifestSchema>;
|
||||
|
||||
const ImportPath = z.string().trim().min(1).max(1024);
|
||||
|
||||
/**
|
||||
* An export manifest restored as a new bot. History is not replayed: the bot starts
|
||||
* its own conversation, so extra keys such as `history` are accepted and dropped.
|
||||
*/
|
||||
export const ImportBotInput = z.object({
|
||||
version: z.literal(1),
|
||||
bot: CreateBotInput.pick({ name: true, title: true, description: true, instructions: true }),
|
||||
memory: z.array(z.object({ path: ImportPath, content: z.string() })).max(500),
|
||||
routines: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
prompt: z.string().min(1),
|
||||
crons: z.array(z.string().min(1)).max(20),
|
||||
timezone: z.string().min(1).max(100),
|
||||
}),
|
||||
)
|
||||
.max(100),
|
||||
files: z.array(z.object({ path: ImportPath, content: z.string() })).max(5000),
|
||||
});
|
||||
export type ImportBotInput = z.infer<typeof ImportBotInput>;
|
||||
|
||||
/** Categories of durable knowledge about the user. */
|
||||
export const USER_FACT_CATEGORIES = [
|
||||
"identity",
|
||||
|
||||
@ -50,6 +50,7 @@ import {
|
||||
ExternalConversationPolicySchema,
|
||||
GroupDetailSchema,
|
||||
GroupSchema,
|
||||
ImportBotInput,
|
||||
IntegrationCatalogResultSchema,
|
||||
MainConversationRefSchema,
|
||||
McpServerConfigInput,
|
||||
@ -333,6 +334,8 @@ export const appContract = {
|
||||
/** The user's main bot, created with its main conversation in the home space when missing. */
|
||||
ensureMain: oc.output(BotSchema),
|
||||
duplicate: oc.input(botId).output(BotSchema),
|
||||
/** A new bot restored from an `export.bot` manifest. */
|
||||
import: oc.input(ImportBotInput).output(BotSchema),
|
||||
reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })),
|
||||
update: oc.input(UpdateBotInput).output(BotSchema),
|
||||
/** Builtin tool names this bot's allowlist can choose from. */
|
||||
|
||||
@ -1613,6 +1613,68 @@ describeJourneys("required product journeys", () => {
|
||||
expect(rawJson).not.toMatch(/browserProfile|ciphertext|sessionCookie/i);
|
||||
});
|
||||
|
||||
it("9b: an export restores as a new bot with memory, files and paused routines", async () => {
|
||||
const cookie = await signup(app, `import-j-${stamp}@rakazo.test`, "Import");
|
||||
const source = await rpc<Bot>(app, cookie, "bots/create", {
|
||||
name: "Scout",
|
||||
title: "Researcher",
|
||||
description: "Finds sources",
|
||||
instructions: "Cite everything",
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
await sendAndWait(
|
||||
app,
|
||||
cookie,
|
||||
source.id,
|
||||
"write a file in your home called notes/result.txt that says import-ok",
|
||||
);
|
||||
await rpc(app, cookie, "routines/create", {
|
||||
botId: source.id,
|
||||
name: "Morning brief",
|
||||
prompt: "Summarize the news",
|
||||
crons: ["0 9 * * *"],
|
||||
timezone: "UTC",
|
||||
active: true,
|
||||
});
|
||||
const manifest = await rpc<{
|
||||
memory: Array<{ path: string; content: string }>;
|
||||
files: Array<{ path: string; content: string }>;
|
||||
}>(app, cookie, "export/bot", { botId: source.id });
|
||||
expect(manifest.files.some((file) => file.content.includes("import-ok"))).toBe(true);
|
||||
|
||||
const imported = await rpc<Bot>(app, cookie, "bots/import", manifest);
|
||||
expect(imported.id).not.toBe(source.id);
|
||||
expect(imported).toMatchObject({
|
||||
name: "Scout",
|
||||
title: "Researcher",
|
||||
description: "Finds sources",
|
||||
instructions: "Cite everything",
|
||||
});
|
||||
const roundTrip = await rpc<{
|
||||
memory: Array<{ path: string; content: string }>;
|
||||
files: Array<{ path: string; content: string }>;
|
||||
}>(app, cookie, "export/bot", { botId: imported.id });
|
||||
expect(roundTrip.files.map((file) => file.content)).toContain(
|
||||
manifest.files.find((file) => file.content.includes("import-ok"))?.content,
|
||||
);
|
||||
expect(roundTrip.files.every((file) => !file.path.includes(source.id))).toBe(true);
|
||||
for (const doc of manifest.memory) {
|
||||
expect(roundTrip.memory).toContainEqual(doc);
|
||||
}
|
||||
const routines = await prisma.routine.findMany({ where: { botId: imported.id } });
|
||||
expect(routines).toHaveLength(1);
|
||||
expect(routines[0]).toMatchObject({ name: "Morning brief", active: false, nextRunAt: null });
|
||||
|
||||
const other = await signup(app, `import-other-j-${stamp}@rakazo.test`, "Other");
|
||||
const unsafe = await raw(app, other, "bots/import", {
|
||||
...manifest,
|
||||
files: [{ path: "../escape.txt", content: "x" }],
|
||||
});
|
||||
expect(unsafe.status).toBe(400);
|
||||
const otherBots = await rpc<Bot[]>(app, other, "bots/list");
|
||||
expect(otherBots.some((bot) => bot.name === "Scout")).toBe(false);
|
||||
});
|
||||
|
||||
it("10: bots can be archived safely and deleted with or without their memories", async () => {
|
||||
const ada = await signup(app, `delete-j-${stamp}@rakazo.test`, "Delete Ada");
|
||||
const bob = await signup(app, `delete-bob-j-${stamp}@rakazo.test`, "Delete Bob");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user